From a10dc1a6f8a22460edb4d9c3c00da7a6efeb41f6 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Mon, 15 Jun 2026 14:39:46 +0200 Subject: [PATCH] Add lightning-mobile support --- composeApp/build.gradle.kts | 46 +- .../src/androidMain/AndroidManifest.xml | 3 + .../auxiliary/android/InomboloApplication.kt | 29 + .../ac/cord/auxiliary/android/MainActivity.kt | 20 + .../auxiliary/compose/AppVersion.android.kt | 10 + .../ui/composable/widgets/nfc/HceMonitor.kt | 82 + .../widgets/nfc/NfcReaderMonitor.kt | 83 + .../ui/composable/widgets/nfc/NfcState.kt | 39 + .../acinq/phoenix/android/BusinessManager.kt | 327 ++++ .../phoenix/android/services/BootReceiver.kt | 34 + .../android/services/ChannelsWatcher.kt | 196 +++ .../android/services/ContactsPhotoCleaner.kt | 119 ++ .../phoenix/android/services/DailyConnect.kt | 159 ++ .../phoenix/android/services/HceService.kt | 198 +++ .../services/InflightPaymentsWatcher.kt | 226 +++ .../services/PaymentsForegroundService.kt | 169 ++ .../phoenix/data/ElectrumServers.android.kt | 6 + .../fr/acinq/phoenix/db/DbFactory.android.kt | 52 + .../fr/acinq/phoenix/db/DbHooks.android.kt | 16 + .../managers/DataStoreManager.android.kt | 14 + .../managers/global/NetworkMonitor.android.kt | 90 + .../security/KeyStoreFunctions.android.kt | 23 + .../acinq/phoenix/security/KeystoreHelper.kt | 116 ++ .../phoenix/utils/ContactsPhotoHelper.kt | 91 + .../kotlin/fr/acinq/phoenix/utils/Logging.kt | 62 + .../phoenix/utils/PlatformContext.android.kt | 16 + .../phoenix/utils/SystemNotificationHelper.kt | 336 ++++ .../phoenix/utils/converters/DateFormatter.kt | 56 + .../extensions/AndroidContextExtensions.kt | 39 + .../extensions/TechnicalExtensions.android.kt | 35 + .../utils/extensions/TechnicalExtensions.kt | 44 + .../utils/logger/LoggerConfig.android.kt | 31 + .../acinq/phoenix/utils/nfc/ApduCommands.kt | 48 + .../fr/acinq/phoenix/utils/nfc/NfcHelper.kt | 50 + .../fr/acinq/phoenix/utils/nfc/NfcParser.kt | 72 + .../phoenix/utils/nfc/NfcReaderCallback.kt | 141 ++ .../src/androidMain/res/values/strings.xml | 1496 +++++++++++++++- .../composeResources/values/strings.xml | 1497 +++++++++++++++++ .../ac/cord/auxiliary/compose/AppVersion.kt | 6 + .../composable/widgets/buttons/Clickable.kt | 83 + .../widgets/dialogs/BottomSheetDialog.kt | 88 + .../composable/widgets/wallet/WalletAvatar.kt | 112 ++ .../commonMain/kotlin/fr/acinq/conf/Lsp.kt | 56 + .../commonMain/kotlin/fr/acinq/conf/Seed.kt | 36 + .../kotlin/fr/acinq/conf/SeedSpec.kt | 9 + .../kotlin/fr/acinq/phoenix/Ambients.kt | 55 + .../fr/acinq/phoenix/PhoenixBusiness.kt | 163 ++ .../kotlin/fr/acinq/phoenix/PhoenixGlobal.kt | 61 + .../phoenix/controllers/AppController.kt | 75 + .../phoenix/controllers/ControllerFactory.kt | 31 + .../fr/acinq/phoenix/controllers/MVI.kt | 32 + .../config/CloseChannelsConfiguration.kt | 42 + .../CloseChannelsConfigurationController.kt | 170 ++ .../controllers/config/Configuration.kt | 13 + .../config/ConfigurationController.kt | 35 + .../config/ElectrumConfiguration.kt | 26 + .../config/ElectrumConfigurationController.kt | 62 + .../controllers/init/InitController.kt | 34 + .../controllers/init/Initialization.kt | 29 + .../phoenix/controllers/init/RestoreWallet.kt | 79 + .../init/RestoreWalletController.kt | 65 + .../acinq/phoenix/controllers/main/Content.kt | 15 + .../controllers/main/ContentController.kt | 39 + .../fr/acinq/phoenix/controllers/main/Home.kt | 17 + .../controllers/main/HomeController.kt | 31 + .../phoenix/controllers/payments/Receive.kt | 22 + .../controllers/payments/ReceiveController.kt | 60 + .../kotlin/fr/acinq/phoenix/csv/CsvWriter.kt | 86 + .../phoenix/csv/WalletPaymentCsvWriter.kt | 278 +++ .../fr/acinq/phoenix/data/AppConfiguration.kt | 291 ++++ .../fr/acinq/phoenix/data/BitcoinAddress.kt | 64 + .../phoenix/data/ChannelsWatcherOutcome.kt | 17 + .../fr/acinq/phoenix/data/ContactInfo.kt | 115 ++ .../acinq/phoenix/data/DecryptSeedResult.kt | 13 + .../fr/acinq/phoenix/data/DefaultOffer.kt | 32 + .../fr/acinq/phoenix/data/ElectrumServers.kt | 184 ++ .../fr/acinq/phoenix/data/ExchangeRates.kt | 177 ++ .../fr/acinq/phoenix/data/LocalChannelInfo.kt | 188 +++ .../fr/acinq/phoenix/data/MempoolFeerate.kt | 54 + .../fr/acinq/phoenix/data/Notification.kt | 93 + .../acinq/phoenix/data/StartBusinessResult.kt | 12 + .../kotlin/fr/acinq/phoenix/data/UserTheme.kt | 14 + .../kotlin/fr/acinq/phoenix/data/Wallet.kt | 55 + .../fr/acinq/phoenix/data/WalletContext.kt | 8 + .../fr/acinq/phoenix/data/WalletNotice.kt | 3 + .../fr/acinq/phoenix/data/WalletPayment.kt | 146 ++ .../fr/acinq/phoenix/data/lnurl/Lnurl.kt | 252 +++ .../fr/acinq/phoenix/data/lnurl/LnurlAuth.kt | 172 ++ .../fr/acinq/phoenix/data/lnurl/LnurlError.kt | 68 + .../fr/acinq/phoenix/data/lnurl/LnurlPay.kt | 221 +++ .../acinq/phoenix/data/lnurl/LnurlWithdraw.kt | 33 + .../kotlin/fr/acinq/phoenix/db/DbFactory.kt | 26 + .../kotlin/fr/acinq/phoenix/db/DbHooks.kt | 53 + .../fr/acinq/phoenix/db/DbInitHelper.kt | 135 ++ .../kotlin/fr/acinq/phoenix/db/SqliteAppDb.kt | 176 ++ .../fr/acinq/phoenix/db/SqliteChannelsDb.kt | 86 + .../fr/acinq/phoenix/db/SqlitePaymentsDb.kt | 324 ++++ .../fr/acinq/phoenix/db/cloud/CloudHelper.kt | 17 + .../phoenix/db/cloud/CloudSerializers.kt | 148 ++ .../phoenix/db/cloud/contacts/CloudContact.kt | 109 ++ .../db/cloud/payments/ChannelCloseType.kt | 47 + .../phoenix/db/cloud/payments/CloudAsset.kt | 135 ++ .../phoenix/db/cloud/payments/CloudData.kt | 158 ++ .../InboundLiquidityPaymentWrapper.kt | 146 ++ .../IncomingPaymentWrapperV10Legacy.kt | 46 + .../payments/LightningOutgoingPartType.kt | 68 + .../cloud/payments/LightningOutgoingType.kt | 109 ++ .../cloud/payments/SpliceCpfpPaymentType.kt | 37 + .../db/cloud/payments/SpliceOutgoingType.kt | 40 + .../phoenix/db/contacts/ContactQueries.kt | 77 + .../phoenix/db/contacts/SqliteContactsDb.kt | 171 ++ .../db/migrations/appDb/v7/AfterVersion7.kt | 353 ++++ .../db/migrations/v10/AfterVersion10.kt | 97 ++ .../v10/json/AbstractStringSerializer.kt | 40 + .../v10/json/ByteVectorSerializer.kt | 40 + .../v10/json/MilliSatoshiSerializer.kt | 42 + .../migrations/v10/json/OutpointSerializer.kt | 30 + .../migrations/v10/json/SatoshiSerializer.kt | 37 + .../db/migrations/v10/json/TxIdSerializer.kt | 25 + .../db/migrations/v10/json/UUIDSerializer.kt | 42 + .../db/migrations/v10/types/IncomingTypes.kt | 496 ++++++ .../db/migrations/v11/AfterVersion11.kt | 477 ++++++ .../queries/ChannelCloseOutgoingQueries.kt | 60 + .../v11/queries/InboundLiquidityQueries.kt | 113 ++ .../v11/queries/LightningOutgoingQueries.kt | 221 +++ .../v11/queries/SpliceCpfpOutgoingQueries.kt | 46 + .../v11/queries/SpliceOutgoingQueries.kt | 51 + .../v11/types/OutgoingDetailsType.kt | 99 ++ .../v11/types/OutgoingPartClosingType.kt | 42 + .../v11/types/OutgoingPartStatusType.kt | 96 ++ .../v11/types/OutgoingStatusType.kt | 108 ++ .../v11/types/liquidityads/FundingFeeData.kt | 46 + .../v11/types/liquidityads/LegacyLeaseData.kt | 67 + .../types/liquidityads/PaymentDetailsData.kt | 65 + .../v11/types/liquidityads/PurchaseData.kt | 89 + .../db/notifications/NotificationDataType.kt | 116 ++ .../db/notifications/NotificationsQueries.kt | 168 ++ .../phoenix/db/payments/CloudKitInterface.kt | 5 + .../phoenix/db/payments/MetadataTypes.kt | 324 ++++ .../db/payments/PaymentsMetadataQueries.kt | 154 ++ .../db/payments/SqliteIncomingPaymentsDb.kt | 149 ++ .../db/payments/SqliteOutgoingPaymentsDb.kt | 176 ++ .../serialization/contacts/Serialization.kt | 19 + .../contacts/v1/Deserialization.kt | 48 + .../contacts/v1/Serialization.kt | 49 + .../managers/AppConfigurationManager.kt | 96 ++ .../phoenix/managers/AppConnectionsDaemon.kt | 463 +++++ .../acinq/phoenix/managers/BalanceManager.kt | 102 ++ .../phoenix/managers/ConnectionsManager.kt | 61 + .../phoenix/managers/DataStoreManager.kt | 143 ++ .../acinq/phoenix/managers/DatabaseManager.kt | 129 ++ .../fr/acinq/phoenix/managers/LnurlManager.kt | 192 +++ .../phoenix/managers/NodeParamsManager.kt | 110 ++ .../phoenix/managers/NotificationsManager.kt | 144 ++ .../phoenix/managers/PaymentMetadataQueue.kt | 64 + .../acinq/phoenix/managers/PaymentsManager.kt | 121 ++ .../phoenix/managers/PaymentsPageFetcher.kt | 228 +++ .../fr/acinq/phoenix/managers/PeerManager.kt | 281 ++++ .../fr/acinq/phoenix/managers/PinManager.kt | 142 ++ .../fr/acinq/phoenix/managers/SeedManager.kt | 182 ++ .../fr/acinq/phoenix/managers/SendManager.kt | 668 ++++++++ .../acinq/phoenix/managers/WalletManager.kt | 104 ++ .../managers/global/CurrencyManager.kt | 424 +++++ .../phoenix/managers/global/FeerateManager.kt | 111 ++ .../phoenix/managers/global/NetworkMonitor.kt | 18 + .../managers/global/WalletContextManager.kt | 190 +++ .../global/fiatapis/BlockchainInfoApi.kt | 78 + .../managers/global/fiatapis/BluelyticsApi.kt | 75 + .../managers/global/fiatapis/CoinbaseApi.kt | 81 + .../global/fiatapis/ExchangeRateApi.kt | 83 + .../managers/global/fiatapis/YadioApi.kt | 79 + .../acinq/phoenix/security/EncryptedData.kt | 131 ++ .../fr/acinq/phoenix/security/EncryptedPin.kt | 59 + .../phoenix/security/EncryptedPinLock.kt | 73 + .../phoenix/security/EncryptedPinSpending.kt | 79 + .../acinq/phoenix/security/EncryptedSeed.kt | 133 ++ .../phoenix/security/KeyStoreFunctions.kt | 5 + .../acinq/phoenix/security/KeyStoreNames.kt | 9 + .../acinq/phoenix/utils/BlockchainExplorer.kt | 59 + .../kotlin/fr/acinq/phoenix/utils/Cache.kt | 248 +++ .../fr/acinq/phoenix/utils/DateUtils.kt | 18 + .../fr/acinq/phoenix/utils/DnsResolvers.kt | 82 + .../acinq/phoenix/utils/MnemonicLanguage.kt | 83 + .../kotlin/fr/acinq/phoenix/utils/Parser.kt | 224 +++ .../fr/acinq/phoenix/utils/PlatformContext.kt | 25 + .../utils/channels/ChannelsImportHelper.kt | 78 + .../channels/SpendChannelAddressHelper.kt | 144 ++ .../utils/converters/AmountConverter.kt | 137 ++ .../utils/converters/AmountFormatter.kt | 118 ++ .../utils/extensions/ChainExtensions.kt | 16 + .../utils/extensions/ChannelExtensions.kt | 76 + .../utils/extensions/ConnectionExtensions.kt | 33 + .../utils/extensions/MiscExtensions.kt | 31 + .../utils/extensions/PaymentExtensions.kt | 76 + .../extensions/PaymentRequestExtensions.kt | 43 + .../utils/extensions/TechnicalExtensions.kt | 27 + .../utils/extensions/WalletStateExtensions.kt | 50 + .../phoenix/utils/logger/LoggerConfig.kt | 40 + .../utils/migrations/IosMigrationHelper.kt | 169 ++ .../migrations/LegacyChannelCloseHelper.kt | 94 ++ .../phoenix/utils/preferences/GlobalPrefs.kt | 133 ++ .../utils/preferences/InternalPrefs.kt | 115 ++ .../phoenix/utils/preferences/UserPrefs.kt | 383 +++++ .../preferences/UserPrefsComposeExtensions.kt | 42 + .../phoenix/db/sqldelight/ExchangeRates.sq | 26 + .../phoenix/db/sqldelight/KeyValueStore.sq | 22 + .../phoenix/db/sqldelight/Notifications.sq | 42 + .../phoenix/db/sqldelight/migrations/1.sqm | 10 + .../phoenix/db/sqldelight/migrations/2.sqm | 15 + .../phoenix/db/sqldelight/migrations/3.sqm | 12 + .../phoenix/db/sqldelight/migrations/4.sqm | 22 + .../phoenix/db/sqldelight/migrations/5.sqm | 7 + .../phoenix/db/sqldelight/migrations/6.sqm | 23 + .../phoenix/db/sqldelight/migrations/7.sqm | 26 + .../phoenix/db/sqldelight/migrations/8.sqm | 10 + .../phoenix/db/sqldelight/ChannelsDatabase.sq | 48 + .../phoenix/db/sqldelight/CloudKitContacts.sq | 85 + .../phoenix/db/sqldelight/CloudKitPayments.sq | 90 + .../acinq/phoenix/db/sqldelight/Contacts.sq | 36 + .../db/sqldelight/OnChainTransactions.sq | 35 + .../acinq/phoenix/db/sqldelight/Payments.sq | 59 + .../phoenix/db/sqldelight/PaymentsIncoming.sq | 90 + .../phoenix/db/sqldelight/PaymentsMetadata.sq | 65 + .../phoenix/db/sqldelight/PaymentsOutgoing.sq | 99 ++ .../sqldelight/paymentsdb/migrations/1.sqm | 24 + .../sqldelight/paymentsdb/migrations/10.sqm | 22 + .../sqldelight/paymentsdb/migrations/11.sqm | 92 + .../sqldelight/paymentsdb/migrations/12.sqm | 37 + .../sqldelight/paymentsdb/migrations/2.sqm | 22 + .../sqldelight/paymentsdb/migrations/3.sqm | 12 + .../sqldelight/paymentsdb/migrations/4.sqm | 8 + .../sqldelight/paymentsdb/migrations/5.sqm | 22 + .../sqldelight/paymentsdb/migrations/6.sqm | 17 + .../sqldelight/paymentsdb/migrations/7.sqm | 57 + .../sqldelight/paymentsdb/migrations/8.sqm | 18 + .../sqldelight/paymentsdb/migrations/9.sqm | 8 + .../cord/auxiliary/compose/AppVersion.ios.kt | 13 + .../acinq/phoenix/data/ElectrumServers.ios.kt | 6 + .../fr/acinq/phoenix/db/CloudKitContactsDb.kt | 313 ++++ .../kotlin/fr/acinq/phoenix/db/CloudKitDb.kt | 13 + .../fr/acinq/phoenix/db/CloudKitPaymentsDb.kt | 375 +++++ .../fr/acinq/phoenix/db/DbFactory.ios.kt | 89 + .../kotlin/fr/acinq/phoenix/db/DbHooks.ios.kt | 36 + .../fr/acinq/phoenix/ios/BusinessManager.kt | 314 ++++ .../phoenix/managers/DataStoreManager.ios.kt | 27 + .../managers/global/NetworkMonitor.ios.kt | 93 + .../fr/acinq/phoenix/security/CCCipher.kt | 177 ++ .../acinq/phoenix/security/KeyChainHelper.kt | 264 +++ .../phoenix/security/KeyStoreFunctions.ios.kt | 26 + .../phoenix/utils/PlatformContext.ios.kt | 31 + .../extensions/TechnicalExtensions.ios.kt | 15 + .../phoenix/utils/logger/LoggerConfig.ios.kt | 15 + .../cord/auxiliary/compose/AppVersion.jvm.kt | 8 + gradle/libs.versions.toml | 15 +- 254 files changed, 26676 insertions(+), 8 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/ac/cord/auxiliary/android/InomboloApplication.kt create mode 100644 composeApp/src/androidMain/kotlin/ac/cord/auxiliary/compose/AppVersion.android.kt create mode 100644 composeApp/src/androidMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/nfc/HceMonitor.kt create mode 100644 composeApp/src/androidMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/nfc/NfcReaderMonitor.kt create mode 100644 composeApp/src/androidMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/nfc/NfcState.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/BusinessManager.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/BootReceiver.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/ChannelsWatcher.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/ContactsPhotoCleaner.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/DailyConnect.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/HceService.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/InflightPaymentsWatcher.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/PaymentsForegroundService.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/data/ElectrumServers.android.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/db/DbFactory.android.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/db/DbHooks.android.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/managers/DataStoreManager.android.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/managers/global/NetworkMonitor.android.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/security/KeyStoreFunctions.android.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/security/KeystoreHelper.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/ContactsPhotoHelper.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/Logging.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/PlatformContext.android.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/SystemNotificationHelper.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/converters/DateFormatter.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/extensions/AndroidContextExtensions.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.android.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/logger/LoggerConfig.android.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/ApduCommands.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/NfcHelper.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/NfcParser.kt create mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/NfcReaderCallback.kt create mode 100644 composeApp/src/commonMain/composeResources/values/strings.xml create mode 100644 composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/AppVersion.kt create mode 100644 composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/buttons/Clickable.kt create mode 100644 composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/dialogs/BottomSheetDialog.kt create mode 100644 composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/wallet/WalletAvatar.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/conf/Lsp.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/conf/Seed.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/conf/SeedSpec.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/Ambients.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/PhoenixBusiness.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/PhoenixGlobal.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/AppController.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/ControllerFactory.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/MVI.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/CloseChannelsConfiguration.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/CloseChannelsConfigurationController.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/Configuration.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/ConfigurationController.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/ElectrumConfiguration.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/ElectrumConfigurationController.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/InitController.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/Initialization.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/RestoreWallet.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/RestoreWalletController.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/Content.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/ContentController.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/Home.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/HomeController.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/payments/Receive.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/payments/ReceiveController.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/csv/CsvWriter.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/csv/WalletPaymentCsvWriter.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/AppConfiguration.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/BitcoinAddress.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ChannelsWatcherOutcome.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ContactInfo.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/DecryptSeedResult.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/DefaultOffer.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ElectrumServers.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ExchangeRates.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/LocalChannelInfo.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/MempoolFeerate.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/Notification.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/StartBusinessResult.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/UserTheme.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/Wallet.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/WalletContext.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/WalletNotice.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/WalletPayment.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/Lnurl.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlAuth.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlError.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlPay.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlWithdraw.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbFactory.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbHooks.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbInitHelper.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/SqliteAppDb.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/SqliteChannelsDb.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/SqlitePaymentsDb.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/CloudHelper.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/CloudSerializers.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/contacts/CloudContact.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/ChannelCloseType.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/CloudAsset.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/CloudData.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/InboundLiquidityPaymentWrapper.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/IncomingPaymentWrapperV10Legacy.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/LightningOutgoingPartType.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/LightningOutgoingType.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/SpliceCpfpPaymentType.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/SpliceOutgoingType.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/contacts/ContactQueries.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/contacts/SqliteContactsDb.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/appDb/v7/AfterVersion7.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/AfterVersion10.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/AbstractStringSerializer.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/ByteVectorSerializer.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/MilliSatoshiSerializer.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/OutpointSerializer.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/SatoshiSerializer.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/TxIdSerializer.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/UUIDSerializer.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/types/IncomingTypes.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/AfterVersion11.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/ChannelCloseOutgoingQueries.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/InboundLiquidityQueries.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/LightningOutgoingQueries.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/SpliceCpfpOutgoingQueries.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/SpliceOutgoingQueries.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingDetailsType.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingPartClosingType.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingPartStatusType.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingStatusType.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/FundingFeeData.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/LegacyLeaseData.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/PaymentDetailsData.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/PurchaseData.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/notifications/NotificationDataType.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/notifications/NotificationsQueries.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/CloudKitInterface.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/MetadataTypes.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/PaymentsMetadataQueries.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/SqliteIncomingPaymentsDb.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/SqliteOutgoingPaymentsDb.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/serialization/contacts/Serialization.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/serialization/contacts/v1/Deserialization.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/serialization/contacts/v1/Serialization.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/AppConfigurationManager.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/AppConnectionsDaemon.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/BalanceManager.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/ConnectionsManager.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/DataStoreManager.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/DatabaseManager.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/LnurlManager.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/NodeParamsManager.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/NotificationsManager.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PaymentMetadataQueue.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PaymentsManager.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PaymentsPageFetcher.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PeerManager.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PinManager.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/SeedManager.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/SendManager.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/WalletManager.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/CurrencyManager.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/FeerateManager.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/NetworkMonitor.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/WalletContextManager.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/BlockchainInfoApi.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/BluelyticsApi.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/CoinbaseApi.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/ExchangeRateApi.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/YadioApi.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedData.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedPin.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedPinLock.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedPinSpending.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedSeed.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/KeyStoreFunctions.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/KeyStoreNames.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/BlockchainExplorer.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/Cache.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/DateUtils.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/DnsResolvers.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/MnemonicLanguage.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/Parser.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/PlatformContext.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/channels/ChannelsImportHelper.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/channels/SpendChannelAddressHelper.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/converters/AmountConverter.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/converters/AmountFormatter.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/ChainExtensions.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/ChannelExtensions.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/ConnectionExtensions.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/MiscExtensions.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/PaymentExtensions.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/PaymentRequestExtensions.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/WalletStateExtensions.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/logger/LoggerConfig.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/migrations/IosMigrationHelper.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/migrations/LegacyChannelCloseHelper.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/GlobalPrefs.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/InternalPrefs.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/UserPrefs.kt create mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/UserPrefsComposeExtensions.kt create mode 100644 composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/ExchangeRates.sq create mode 100644 composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/KeyValueStore.sq create mode 100644 composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/Notifications.sq create mode 100644 composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/1.sqm create mode 100644 composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/2.sqm create mode 100644 composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/3.sqm create mode 100644 composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/4.sqm create mode 100644 composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/5.sqm create mode 100644 composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/6.sqm create mode 100644 composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/7.sqm create mode 100644 composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/8.sqm create mode 100644 composeApp/src/commonMain/sqldelight/channelsdb/fr/acinq/phoenix/db/sqldelight/ChannelsDatabase.sq create mode 100644 composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/CloudKitContacts.sq create mode 100644 composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/CloudKitPayments.sq create mode 100644 composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/Contacts.sq create mode 100644 composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/OnChainTransactions.sq create mode 100644 composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/Payments.sq create mode 100644 composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsIncoming.sq create mode 100644 composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsMetadata.sq create mode 100644 composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsOutgoing.sq create mode 100644 composeApp/src/commonMain/sqldelight/paymentsdb/migrations/1.sqm create mode 100644 composeApp/src/commonMain/sqldelight/paymentsdb/migrations/10.sqm create mode 100644 composeApp/src/commonMain/sqldelight/paymentsdb/migrations/11.sqm create mode 100644 composeApp/src/commonMain/sqldelight/paymentsdb/migrations/12.sqm create mode 100644 composeApp/src/commonMain/sqldelight/paymentsdb/migrations/2.sqm create mode 100644 composeApp/src/commonMain/sqldelight/paymentsdb/migrations/3.sqm create mode 100644 composeApp/src/commonMain/sqldelight/paymentsdb/migrations/4.sqm create mode 100644 composeApp/src/commonMain/sqldelight/paymentsdb/migrations/5.sqm create mode 100644 composeApp/src/commonMain/sqldelight/paymentsdb/migrations/6.sqm create mode 100644 composeApp/src/commonMain/sqldelight/paymentsdb/migrations/7.sqm create mode 100644 composeApp/src/commonMain/sqldelight/paymentsdb/migrations/8.sqm create mode 100644 composeApp/src/commonMain/sqldelight/paymentsdb/migrations/9.sqm create mode 100644 composeApp/src/iosMain/kotlin/ac/cord/auxiliary/compose/AppVersion.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/data/ElectrumServers.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitContactsDb.kt create mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitDb.kt create mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitPaymentsDb.kt create mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/DbFactory.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/DbHooks.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/ios/BusinessManager.kt create mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/managers/DataStoreManager.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/managers/global/NetworkMonitor.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/security/CCCipher.kt create mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/security/KeyChainHelper.kt create mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/security/KeyStoreFunctions.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/utils/PlatformContext.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/utils/logger/LoggerConfig.ios.kt create mode 100644 composeApp/src/jvmMain/kotlin/ac/cord/auxiliary/compose/AppVersion.jvm.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index bd98379a..cf834428 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -11,6 +11,7 @@ plugins { alias(libs.plugins.composeHotReload) alias(libs.plugins.kotlinPluginSerialization) alias(libs.plugins.ksp) + alias(libs.plugins.sqldelight) } kotlin { @@ -39,13 +40,14 @@ kotlin { sourceSets { androidMain.dependencies { implementation(libs.androidx.activity.compose) + implementation(libs.androidx.work.runtime.ktx) // implementation(libs.androidx.room.sqlite.wrapper) implementation(libs.compose.uiToolingPreview) implementation(libs.okhttp.coroutines) + + implementation(libs.sqldelight.android.driver) } commonMain.dependencies { - implementation(libs.lightning.kmp.core) - implementation(libs.androidx.datastore) implementation(libs.androidx.datastore.preferences) @@ -72,21 +74,36 @@ kotlin { implementation(libs.compose.uiToolingPreview) - implementation(libs.navigation.compose) - implementation(libs.kermit) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.serialization.cbor) + implementation(libs.ktor.client.core) implementation(libs.ktor.client.cio) implementation(libs.ktor.client.websockets) implementation(libs.ktor.serialization.kotlinx.json) + + + implementation(libs.lightning.kmp.core) + + implementation(libs.navigation.compose) + implementation(libs.okio) + + implementation(libs.sqldelight.runtime) + implementation(libs.sqldelight.coroutines.extensions) + implementation(libs.vitorpamplona.quartz) + + implementation("com.ionspin.kotlin:bignum:0.3.10") + + implementation("no.synth:kmp-zip:0.8.0") + implementation("no.synth:kmp-zip-okio:0.8.0") } commonTest.dependencies { implementation(libs.kotlin.test) @@ -96,6 +113,7 @@ kotlin { implementation(libs.kotlinx.coroutinesSwing) } iosMain.dependencies { + implementation(libs.sqldelight.native.driver) } } } @@ -104,6 +122,9 @@ android { namespace = "ac.cord.auxiliary.android" compileSdk = libs.versions.android.compileSdk.get().toInt() + buildFeatures { + buildConfig = true + } defaultConfig { applicationId = "ac.cord.auxiliary.android" minSdk = libs.versions.android.minSdk.get().toInt() @@ -147,6 +168,23 @@ room3 { schemaDirectory("$projectDir/schemas") } +sqldelight { + databases { + create("ChannelsDatabase") { + packageName.set("fr.acinq.phoenix.db.sqldelight") + srcDirs.from("src/commonMain/sqldelight/channelsdb") + } + create("PaymentsDatabase") { + packageName.set("fr.acinq.phoenix.db.sqldelight") + srcDirs.from("src/commonMain/sqldelight/paymentsdb") + } + create("AppDatabase") { + packageName.set("fr.acinq.phoenix.db.sqldelight") + srcDirs.from("src/commonMain/sqldelight/appdb") + } + } +} + compose.desktop { application { mainClass = "ac.cord.auxiliary.desktop.MainKt" diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 9581de76..fbe5a1c1 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -4,7 +4,10 @@ + + by preferencesDataStore(name = "globalprefs") + +class InomboloApplication: Application() { + lateinit var globalPrefs: GlobalPrefs + lateinit var phoenixGlobal: PhoenixGlobal + + override fun onCreate() { + super.onCreate() + + phoenixGlobal = PhoenixGlobal(PlatformContext(applicationContext)) + globalPrefs = GlobalPrefs(applicationContext.globalPrefs) + BusinessManager.initialize(applicationContext) + + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/ac/cord/auxiliary/android/MainActivity.kt b/composeApp/src/androidMain/kotlin/ac/cord/auxiliary/android/MainActivity.kt index c950c61a..2947484c 100644 --- a/composeApp/src/androidMain/kotlin/ac/cord/auxiliary/android/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/ac/cord/auxiliary/android/MainActivity.kt @@ -3,13 +3,21 @@ package ac.cord.auxiliary.android import ac.cord.auxiliary.compose.AuxApp import ac.cord.auxiliary.compose.AuxGlobal import ac.cord.auxiliary.compose.PlatformContext +import android.content.Intent +import android.nfc.NfcAdapter import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.navigation.compose.rememberNavController +import com.machankura.compose.ui.composable.widgets.nfc.NfcState +import com.machankura.compose.ui.composable.widgets.nfc.NfcStateRepository +import fr.acinq.phoenix.android.services.HceService class MainActivity : ComponentActivity() { + + private var nfcAdapter: NfcAdapter? = null + override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -27,4 +35,16 @@ class MainActivity : ComponentActivity() { ) } } + + fun stopHceService() { + stopService(Intent(this@MainActivity, HceService::class.java)) + } + + fun stopNfcReader() { + if (NfcStateRepository.isReading()) { + NfcStateRepository.updateState(NfcState.Inactive) + } + nfcAdapter?.disableReaderMode(this@MainActivity) + } + } \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/ac/cord/auxiliary/compose/AppVersion.android.kt b/composeApp/src/androidMain/kotlin/ac/cord/auxiliary/compose/AppVersion.android.kt new file mode 100644 index 00000000..93198ec5 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/ac/cord/auxiliary/compose/AppVersion.android.kt @@ -0,0 +1,10 @@ +package ac.cord.auxiliary.compose + +import ac.cord.auxiliary.android.BuildConfig + +actual object AppVersion { + actual val versionName: String + get() = BuildConfig.VERSION_NAME + actual val versionCode: String + get() = BuildConfig.VERSION_CODE.toString() +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/nfc/HceMonitor.kt b/composeApp/src/androidMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/nfc/HceMonitor.kt new file mode 100644 index 00000000..be61ebcb --- /dev/null +++ b/composeApp/src/androidMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/nfc/HceMonitor.kt @@ -0,0 +1,82 @@ +/* + * 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 com.machankura.compose.ui.composable.widgets.nfc + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.machankura.compose.ui.composable.widgets.dialogs.ModalBottomSheet +import fr.acinq.phoenix.utils.extensions.findActivitySafe + + +/** Displays a blocking bottom sheet when the HCE service is started, using the state in [NfcStateRepository]. */ +@Composable +fun HceMonitorDialog() { + val context = LocalContext.current + val activity = context.findActivitySafe() ?: return + + val state by NfcStateRepository.state.collectAsState(initial = null) + + val onDone = { activity.stopHceService() } + + when (state) { + is NfcState.EmulatingTag -> ModalBottomSheet( + onDismiss = onDone, + scrimAlpha = .5f, + horizontalAlignment = Alignment.CenterHorizontally, + internalPadding = PaddingValues(horizontal = 24.dp), + dismissOnScrimClick = false, + ) { + Text(text = "HCE Emulation", style = MaterialTheme.typography.headlineSmall) +// TODO: Spacer(Modifier.height(16.dp)) +// Image( +// painter = painterResource(id = R.drawable.ic_nfc), +// contentDescription = stringResource(R.string.nfc_button), +// modifier = Modifier.size(64.dp), +// colorFilter = ColorFilter.tint(MaterialTheme.colors.primary) +// ) + Spacer(Modifier.height(24.dp)) + FilledTonalButton( +// TODO: icon = R.drawable.ic_cross, + onClick = onDone, + modifier = Modifier.fillMaxWidth() + ) { + Text("Cancel") + } + Spacer(Modifier.height(24.dp)) + } + is NfcState.ShowReader -> {} + is NfcState.Inactive -> {} + null -> {} + } +} diff --git a/composeApp/src/androidMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/nfc/NfcReaderMonitor.kt b/composeApp/src/androidMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/nfc/NfcReaderMonitor.kt new file mode 100644 index 00000000..266204fa --- /dev/null +++ b/composeApp/src/androidMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/nfc/NfcReaderMonitor.kt @@ -0,0 +1,83 @@ +/* + * 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 com.machankura.compose.ui.composable.widgets.nfc + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.machankura.compose.ui.composable.widgets.dialogs.ModalBottomSheet +import fr.acinq.phoenix.utils.extensions.findActivitySafe + +@Composable +fun NfcReaderMonitor() { + val context = LocalContext.current + val state by NfcStateRepository.state.collectAsState(initial = null) + val activity = context.findActivitySafe() ?: return + + val onDismiss = { activity.stopNfcReader() } + + when (state) { + null -> {} + is NfcState.Inactive -> {} + is NfcState.EmulatingTag -> {} + is NfcState.ShowReader -> { + ModalBottomSheet( + onDismiss = onDismiss, + scrimAlpha = .5f, + horizontalAlignment = Alignment.CenterHorizontally, + internalPadding = PaddingValues(horizontal = 24.dp), + dismissOnScrimClick = false, + ) { + Text(text = "NFC Reader", style = MaterialTheme.typography.headlineSmall) +// TODO: Spacer(Modifier.height(16.dp)) +// Image( +// painter = painterResource(id = R.drawable.ic_nfc), +// contentDescription = stringResource(R.string.nfc_button), +// modifier = Modifier.size(64.dp), +// colorFilter = ColorFilter.tint(MaterialTheme.colors.primary) +// ) + Spacer(Modifier.height(16.dp)) +// Text(text = stringResource(R.string.nfc_reader_desc), style = MaterialTheme.typography.subtitle2) + Spacer(Modifier.height(24.dp)) + FilledTonalButton( +// TODO: icon = R.drawable.ic_cross, + onClick = onDismiss, + modifier = Modifier.fillMaxWidth() + ) { + Text("Cancel") + } + Spacer(Modifier.height(24.dp)) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/nfc/NfcState.kt b/composeApp/src/androidMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/nfc/NfcState.kt new file mode 100644 index 00000000..312a88a4 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/nfc/NfcState.kt @@ -0,0 +1,39 @@ +/* + * 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 com.machankura.compose.ui.composable.widgets.nfc + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +sealed class NfcState { + data object Inactive: NfcState() + sealed class Busy : NfcState() + data object ShowReader: Busy() + data class EmulatingTag(val paymentRequest: String): Busy() +} + +object NfcStateRepository { + private val _state = MutableStateFlow(null) + val state = _state.asStateFlow() + + fun isReading() = state.value is NfcState.ShowReader + fun isEmulating() = state.value is NfcState.EmulatingTag + + fun updateState(s: NfcState) { + _state.value = s + } +} diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/BusinessManager.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/BusinessManager.kt new file mode 100644 index 00000000..8b479274 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/BusinessManager.kt @@ -0,0 +1,327 @@ +/* + * 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 ac.cord.auxiliary.android.InomboloApplication +import ac.cord.auxiliary.compose.AppVersion +import ac.cord.auxiliary.compose.ui.composable.widgets.wallet.WalletAvatars +import android.content.Context +import android.text.format.DateUtils +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.DataStoreManager +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.PeerManager +import fr.acinq.phoenix.managers.WalletManager +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.Job +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 InomboloApplication } + + /** A map of (walletId -> active businesses) */ + private val _businessFlow = MutableStateFlow>(emptyMap()) + val businessFlow = _businessFlow.asStateFlow() + + /** Map of jobs monitoring events/payments once business starts */ + private val eventsMonitoringJobs = mutableMapOf() //List>() + + 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, 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() + } +} diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/BootReceiver.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/BootReceiver.kt new file mode 100644 index 00000000..1941973e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/BootReceiver.kt @@ -0,0 +1,34 @@ +/* + * 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) + } + } +} diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/ChannelsWatcher.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/ChannelsWatcher.kt new file mode 100644 index 00000000..172789a7 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/ChannelsWatcher.kt @@ -0,0 +1,196 @@ +/* + * 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 ac.cord.auxiliary.android.BuildConfig +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 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): 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) + } + + } + + +} diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/ContactsPhotoCleaner.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/ContactsPhotoCleaner.kt new file mode 100644 index 00000000..926bb86f --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/ContactsPhotoCleaner.kt @@ -0,0 +1,119 @@ +/* + * 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 ac.cord.auxiliary.android.BuildConfig +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 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) + } + } +} + diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/DailyConnect.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/DailyConnect.kt new file mode 100644 index 00000000..fea91381 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/DailyConnect.kt @@ -0,0 +1,159 @@ +/* + * 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 ac.cord.auxiliary.android.BuildConfig +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 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) + } + } +} + diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/HceService.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/HceService.kt new file mode 100644 index 00000000..28ebe889 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/HceService.kt @@ -0,0 +1,198 @@ +/* + * 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 Service.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}" + } + } +} diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/InflightPaymentsWatcher.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/InflightPaymentsWatcher.kt new file mode 100644 index 00000000..2f142c1b --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/InflightPaymentsWatcher.kt @@ -0,0 +1,226 @@ +/* + * 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 ac.cord.auxiliary.android.BuildConfig +import ac.cord.auxiliary.android.InomboloApplication +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 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.StartBusinessResult +import fr.acinq.phoenix.managers.SeedManager +import fr.acinq.phoenix.data.LocalChannelInfo +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.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 InomboloApplication).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): 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().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) + } + } +} + diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/PaymentsForegroundService.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/PaymentsForegroundService.kt new file mode 100644 index 00000000..75bab12b --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/PaymentsForegroundService.kt @@ -0,0 +1,169 @@ +package fr.acinq.phoenix.android.services + +import ac.cord.auxiliary.android.BuildConfig +import ac.cord.auxiliary.android.InomboloApplication +import android.app.Notification +import android.app.Service +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.Handler +import android.os.Looper +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.ServiceCompat +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.StartBusinessResult +import fr.acinq.phoenix.data.DecryptSeedResult +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.managers.SeedManager +import fr.acinq.phoenix.managers.AppConnectionsDaemon +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 InomboloApplication).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! + val notif = 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" + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/data/ElectrumServers.android.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/data/ElectrumServers.android.kt new file mode 100644 index 00000000..296d956a --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/data/ElectrumServers.android.kt @@ -0,0 +1,6 @@ +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) \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/db/DbFactory.android.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/db/DbFactory.android.kt new file mode 100644 index 00000000..040f4d98 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/db/DbFactory.android.kt @@ -0,0 +1,52 @@ +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) + } + }) +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/db/DbHooks.android.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/db/DbHooks.android.kt new file mode 100644 index 00000000..fc680b07 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/db/DbHooks.android.kt @@ -0,0 +1,16 @@ +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 +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/managers/DataStoreManager.android.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/managers/DataStoreManager.android.kt new file mode 100644 index 00000000..3f8d3350 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/managers/DataStoreManager.android.kt @@ -0,0 +1,14 @@ +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() +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/managers/global/NetworkMonitor.android.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/managers/global/NetworkMonitor.android.kt new file mode 100644 index 00000000..ab2bdfc7 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/managers/global/NetworkMonitor.android.kt @@ -0,0 +1,90 @@ +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 + + 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) + } +} diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/security/KeyStoreFunctions.android.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/security/KeyStoreFunctions.android.kt new file mode 100644 index 00000000..3416c197 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/security/KeyStoreFunctions.android.kt @@ -0,0 +1,23 @@ +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 = tryWith(GeneralSecurityException()) { + val cipher = KeystoreHelper.getEncryptionCipher(keyName) + Logger.withTag("KeyStoreFunctions").i("IV for encryption: ${cipher.iv.size}") + return Pair( + cipher.iv, + cipher.doFinal(plainText) + ) +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/security/KeystoreHelper.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/security/KeystoreHelper.kt new file mode 100644 index 00000000..a5ef2abf --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/security/KeystoreHelper.kt @@ -0,0 +1,116 @@ +/* + * 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)) + } +} diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/ContactsPhotoHelper.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/ContactsPhotoHelper.kt new file mode 100644 index 00000000..bb50b2f1 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/ContactsPhotoHelper.kt @@ -0,0 +1,91 @@ +package fr.acinq.phoenix.utils + +import ac.cord.auxiliary.android.BuildConfig +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 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 + } + } +} diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/Logging.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/Logging.kt new file mode 100644 index 00000000..92338d3f --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/Logging.kt @@ -0,0 +1,62 @@ +/* + * 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 + } + +} diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/PlatformContext.android.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/PlatformContext.android.kt new file mode 100644 index 00000000..a5f224ab --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/PlatformContext.android.kt @@ -0,0 +1,16 @@ +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 \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/SystemNotificationHelper.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/SystemNotificationHelper.kt new file mode 100644 index 00000000..8df92a9b --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/SystemNotificationHelper.kt @@ -0,0 +1,336 @@ +/* + * 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 ac.cord.auxiliary.android.BuildConfig +import ac.cord.auxiliary.android.MainActivity +import ac.cord.auxiliary.android.R +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +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 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 -> { + val remainingTimeMillis = 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, + ): 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()) + } + } + } + +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/converters/DateFormatter.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/converters/DateFormatter.kt new file mode 100644 index 00000000..b3f1650c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/converters/DateFormatter.kt @@ -0,0 +1,56 @@ +/* + * 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 androidx.compose.ui.res.stringResource +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)) +} diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/extensions/AndroidContextExtensions.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/extensions/AndroidContextExtensions.kt new file mode 100644 index 00000000..af1a4c11 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/extensions/AndroidContextExtensions.kt @@ -0,0 +1,39 @@ +/* + * 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 ac.cord.auxiliary.android.MainActivity +import android.content.Context +import android.content.ContextWrapper + +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") +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.android.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.android.kt new file mode 100644 index 00000000..e5580571 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.android.kt @@ -0,0 +1,35 @@ +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) + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.kt new file mode 100644 index 00000000..7773c1e3 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.kt @@ -0,0 +1,44 @@ +/* + * 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 tryWith(exception: Exception, action: () -> T): T = try { + action.invoke() +} catch (t: Exception) { + exception.initCause(t) + throw exception +} + +inline fun 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.ifLet(block: (T) -> R): R { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + return block(this) +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/logger/LoggerConfig.android.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/logger/LoggerConfig.android.kt new file mode 100644 index 00000000..026cee64 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/logger/LoggerConfig.android.kt @@ -0,0 +1,31 @@ +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 = 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) + } + } +} diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/ApduCommands.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/ApduCommands.kt new file mode 100644 index 00000000..698f388f --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/ApduCommands.kt @@ -0,0 +1,48 @@ +/* + * 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() +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/NfcHelper.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/NfcHelper.kt new file mode 100644 index 00000000..0b2b2f88 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/NfcHelper.kt @@ -0,0 +1,50 @@ +/* + * 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() + 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() + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/NfcParser.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/NfcParser.kt new file mode 100644 index 00000000..28a329b9 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/NfcParser.kt @@ -0,0 +1,72 @@ +/* + * 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) + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/NfcReaderCallback.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/NfcReaderCallback.kt new file mode 100644 index 00000000..2707b6b7 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/NfcReaderCallback.kt @@ -0,0 +1,141 @@ +/* + * 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 fr.acinq.lightning.utils.toByteVector +import com.machankura.compose.ui.composable.widgets.nfc.NfcState +import com.machankura.compose.ui.composable.widgets.nfc.NfcStateRepository +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") + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml index adc8a767..a004a5e6 100644 --- a/composeApp/src/androidMain/res/values/strings.xml +++ b/composeApp/src/androidMain/res/values/strings.xml @@ -1,3 +1,1497 @@ - +a Aux + + Channels watcher + Shows up when you need to start Phoenix. + + Payment finalisation + Tells you when Phoenix needs to be started to settle a pending payment. + + Payment rejected + Shows up when Phoenix cannot receive a payment because of a liquidity issue. + + Payment received + Shows up when you receive a payment while the app is in the background. + + Running in the background + Tells you when Phoenix is running in the background. + + Swap timeout + Tells you when a swap is going to timeout. + + + + Creating your wallet… + Wallet creation failed + This seed has already been imported + The wallet could not be written at this time. Try again later. + + Restore my wallet + Next + Restoring your wallet… + Use a custom Electrum server + + + + Etiam porttitor egestas faucibus. Curabitur condimentum eros non ipsum elementum egestas. + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean ut facilisis lectus. Integer massa tellus, suscipit sit amet felis vitae, blandit consectetur dolor. Fusce volutpat id magna id vestibulum. Integer a erat lacinia, placerat risus a, fermentum justo. Etiam euismod tincidunt dolor vel posuere. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Curabitur non euismod dui. Morbi enim dui, blandit sed erat sit amet, porta pulvinar odio. Cras metus felis, vestibulum eu consequat vitae, consectetur quis nulla. Fusce vulputate, elit et luctus sodales, metus metus elementum sem, eget commodo nunc nunc in ex. Sed aliquam eros nibh, ac volutpat turpis accumsan vitae. Cras suscipit ipsum accumsan aliquam interdum. + + Praesent ut nisi fringilla, pharetra dui sit amet, ornare urna. Donec at ultrices nunc. Fusce gravida metus vitae viverra egestas. In hac habitasse platea dictumst. Proin consequat fringilla felis, vehicula vehicula turpis ullamcorper nec. Pellentesque urna massa, blandit cursus metus et, ultrices consectetur neque. Suspendisse hendrerit venenatis mi ac tincidunt. Morbi hendrerit orci vitae erat luctus, at dignissim turpis accumsan. Integer elementum est eu tincidunt ullamcorper. Phasellus varius porttitor vestibulum. Maecenas faucibus ullamcorper diam, ac commodo dui fringilla sed. Aliquam arcu velit, porta eu sem vel, rhoncus bibendum dolor. + \nEtiam porttitor egestas faucibus. Curabitur condimentum eros non ipsum elementum egestas. Nam tempor euismod erat eget scelerisque. Integer sit amet laoreet erat. Duis enim turpis, vehicula eu justo vitae, dapibus auctor mauris. Aenean euismod eleifend dui a aliquet. Aliquam eleifend malesuada tortor ornare volutpat. In augue tortor, gravida et volutpat elementum, iaculis non sapien. Etiam dolor nisi, pulvinar a rhoncus ac, eleifend ac libero. In lobortis enim vitae ultricies viverra. Maecenas accumsan elementum sem, nec pharetra urna maximus maximus. + \nIn sit amet volutpat ligula, ac pretium dolor. Phasellus posuere rhoncus magna quis fermentum. Ut risus turpis, fermentum facilisis mollis in, porttitor eget erat. Donec luctus egestas ligula et interdum. Phasellus vitae hendrerit sem, at vehicula nulla. Curabitur mollis risus quis metus euismod ullamcorper. Nam eu aliquet mi. Duis id urna ac urna iaculis blandit. Morbi eros dui, congue a posuere efficitur, imperdiet a nisi. Morbi non orci non lorem aliquet tincidunt. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce pulvinar, mi vitae sollicitudin dignissim, nunc urna facilisis massa, ut scelerisque mi felis in sapien. + \nNam felis felix, tristique commodo odio eget, imperdiet viverra erat. Donec venenatis magna pulvinar, finibus leo id, gravida augue. Integer ante leo, bibendum ac nibh quis, auctor commodo quam. Sed luctus vitae quam vel condimentum. Mauris eu rhoncus mauris. Fusce enim diam, consequat a odio sit amet, accumsan cursus nisl. Etiam lectus nunc, lacinia id purus sit amet, pulvinar auctor odio. Maecenas vitae arcu sit amet est cursus maximus. Nullam ac sapien non nibh tempor rhoncus. Mauris dignissim cursus libero quis egestas. + + + + + Unlock to continue + PIN code + System lock + + + + Initialising… + Preparing wallet… + Decrypting… + Starting wallet… + Opening wallet… + Select a wallet + + Could not start the wallet + Unable to read wallet data. + Try again + Unhandled file serialisation + Decryption failure:\n%1$s + Android keystore failure:\n%1$s + Manual recover + + + + Wallet recovery + This screen lets you manually recover a single wallet by entering its 12-words recovery phrase.\n\nWords must be entered in the correct order, and separated with a single space. + Enter word #%1$d + No more than 12 words! + This seed is not valid + + Import seed + Try again + Checking seed… + + An error occurred. + This seed does not match any existing wallet data. + Failed to perform keystore operations. + Recovering wallet… + + + + - + + + Waiting for confirmation + Payment pending + Payment confirmed + Payment complete + Payment has failed + + + + QR Code of the invoice/address + + Address + Synchronizing address… + Bitcoin address + Share this Bitcoin address with… + + Generating… + Single use + Bolt11 + Reusable + Bolt12 + Amount + Description + Could not generate invoice + Lightning payment + Share this Lightning payment code with… + + Bitcoin address + This QR code is a classic Bitcoin address.\n\nIt can be read by almost any Bitcoin service/wallet, but payments will be slower to arrive. + Lightning address + Lightning + This QR code is a Lightning invoice.\n\nLightning payments are very fast and usually cheaper, but some wallets and services may not support them yet.\n\nIn that case, swipe to the left to get a regular Bitcoin address. + Lightning Bolt11 + Lightning Bolt12 + Bitcoin URI + + What is this? + This Lightning address uses the modern Bip353 standard that works with Bolt12 payment requests.\n\nIt is more private than LNURL-based Lightning addresses, and can even be self-hosted.\n\nHowever, it\'s is bleeding edge tech ; some wallets or services do not understand it yet and won\'t be able to pay you. + Learn more. + + Customise this Bolt11 invoice + Customise this Bolt12 invoice + Amount (optional) + Amount to receive + Description (optional) + Enter a description for this invoice + Generate + + + + Balance + Amount is too large. + Amount cannot be negative. + This is not a valid amount. + This amount exceeds your balance. + Cannot pay more than %1$s. + This amount is below the requested amount of %1$s. + Send to + Description + Fee + N/A + Loading fee… + Amountless invoice + The invoice for this payment does not request a specific amount. This may be exploited by malicious nodes during the payment.\n\nTo be safe, ask the recipient to specify an amount when generating the invoice. + Waiting for channels… + Pay + Confirm & Pay + Try again + + Message + Tap to attach a message… + Attach a custom message + The recipient will see this message + Enter a message… + Fetching payment details… + Payment has failed + Could not retrieve payment details within a reasonable time.\n\nThe recipient may be offline or unreachable. + + Pay on-chain + On-chain transactions are typically slower and best suited for large payments. + Pay with Lightning + Lightning payments are fast and best suited for small payments. + + Address + Fee rate + Retrieving current feerate… + + Please enter a valid feerate. + Total exceeds your balance + Payment has failed + + Prepare transaction + Preparing transaction… + Executing splice… + Miner fee + Uses an effective feerate of %1$s sat/vbyte. + Total + + + + Tap to grant camera permission + Camera permission has been denied + Zoom or tap the QR to focus + + + + Send + No contacts yet… + No matches for search… + satoshi@domain, bc1q..., lnbc... + + Paste + Scan QR code + Choose image + This image could not be processed. + No QR code found in this image. + Reading input… + Fetching data from service… + Resolving payment request over DNS… + + You have on-chain funds on the final wallet, but none on Lightning. + View final wallet + + + + + ≈ %1$s + !? + Exchange rate unavailable + + just now + N/A + Processing data… + Loading data… + Loading preferences… + Copied to clipboard! + + ₿%1$s + + Open link in a browser + Open transaction in an explorer + Open address in an explorer + This field cannot be blank + Please enter a valid amount + Please enter a valid number + This field must be an integer + + Go back + Next + Copy + Share + OK + Save + Delete + Confirm + Cancel + Close + + + + Drain my wallet + Loading… + Checking balance… + Balance: %1$s (≈ %2$s) + The wallet does not have any channels that are eligible for closing. + Review closing + This address uses a different blockchain + This address uses unsupported features + This address is not supported + Closing has been initiated. The closing transaction is in your transactions list. + + No channels eligible for closing. + + + + Payment channels + Import channels + Spend channel address + Overview + Balance + Balance is the aggregated balance of your active channels. It\'s what you can spend over Lightning. + Inbound liquidity + Inbound liquidity is what your channels can receive over Lightning without having to go on-chain and pay fees. + Loading channel data… + You don\'t have any channels yet.\n\nA new payment channel will be created automatically when needed. + + + + Channel details + No active channel exists for that identifier. + Channel id + State + Balance + Inbound liquidity + + Active commitments + Inactive commitments + Funding tx: + Balance: + Capacity: + Triggered by: + + Display raw data + Share + Channels data + Share channel data + + + + Import raw channel data + This screen is a debugging tool that can be used to manually import encrypted channels data.\n\nUse with caution. + Data blob + Import + Importing data… + Import successful + You must now restart Phoenix. + Import has failed + Data are malformed. A encrypted hex blob is expected. + Data could not be decrypted by this wallet. + Version %1$d is not supported + + + + Spend channel address + This screen is a debugging tool that helps recover funds that have been accidentally sent to a channel\'s outpoint. + Amount + Tx index + Raw channel data + Remote funding pubkey + Unsigned tx + Sign + Signing… + + Signature successfully generated. + Public key + Signature + + Failed to sign data + Invalid amount + Invalid tx index + Malformed channel data + Cannot decrypt channel data + Unhandled channel state [%1$s] + Malformed channel version [%1$s] + Malformed remote funding pubkey [%1$s] + Malformed unsigned tx [%1$s] + Malformed remote funding pubkey [%1$s] + Malformed transaction [%1$s] + Invalid signature + + + + Loading… + +%1$s + + FAQ + Use the Receive and Send buttons at the bottom of this screen to get started! + Show all payments… + Desync! + Certificate + Invalid address + Connecting… + Tor + Request liquidity + You currently have %1$d payment(s) pending in your wallet.\n\nKeep the app open to make sure these payments settle properly without issues. + + + + Notifications + Important messages + Recent activity + No notifications yet + + + + Recovery phrase + Unlocking seed… + Could not unlock seed + BIP39 seed with standard BIP84 derivation path + Loading preferences… + Backup confirmation + + + + Application logs + Exporting logs… + Error: logs could not be exported + View logs… + View logs with… + Share logs… + Phoenix App logs + Share Phoenix logs… + + + + Loading payment details… + Could not find payment details + + LIQUIDITY ADDED %1$s + CHANNEL MANAGEMENT %1$s + COMPLETE %1$s + SENT %1$s + Pending… + FAILED\nNo money has been sent. + + Not received yet. + Waiting for channel to open + RECEIVED %1$s + + Waiting for confirmations + Fetching status… + 0 confirmation + Tap to accelerate + %1$d confirmation(s) + Confirmed on-chain + + Message + Sent by + Unknown + Be careful with messages from unknown sources + + Serviced by + Message + Link + Open link + Message + Decrypting message… + + Description + Note + Sent to + Bitcoin miners + Fees + Error + No description + This payment happened following a conflict in a channel. + + On-chain payment + Closing channel + Migration from legacy app + Bump transactions + Manual liquidity (+%1$s) + Channel management + Swap-out to %1$s + On-chain deposit + + to %1$s + from %1$s + + Add a custom description to this payment + Description + Attach note + Edit note + Technical details + + + + Technical details + Type of payment + Channel closing + Incoming on-chain payment (splice) + Incoming on-chain payment (new channel) + Incoming payment (legacy pay-to-open) + Incoming on-chain payment (legacy swap-in) + Incoming Lightning payment (bolt11) + Incoming Lightning payment (bolt12) + Outgoing Lightning payment (bolt11) + Outgoing Lightning payment (bolt12) + Outgoing on-chain payment + Outgoing on-chain payment (legacy swap) + Accelerate on-chain transactions + Inbound liquidity request (manual) + Inbound liquidity request (auto) + + Deposit address + Bitcoin address + + Spliced channel + Local inputs + - #%1$s: + + Liquidity requested + + Closing type + Mutual + Local + Remote + Revoked + Other + + Target public key + Payment Hash + Preimage + Cryptographic proof that the recipient successfully received the payment. + Bolt11 invoice + Invoice description + Bolt12 invoice + Offer + Metadata + Payer key + Your offer key + Purchase type + + Payment status + Successful + Confirming + Pending + Failed + + Payment parts (%1$d) + Part + Hops + + Received via + Channel operation (new or splice-in) + Lightning payment + Fee credit + Channel id + Transaction + + Created at + Completed at + Elapsed + %1$s ms + + Amount requested + Amount sent (fees included) + Amount received + Fee credit accrued + Amount added to fee credit + ≈ %1$s (now) + ≈ %1$s (then) + + + + Add contact + Send a payment + + Create new contact… + + Name cannot be left empty + Invalid Bolt12 code + Code already attached to \"%1$s\" + Invalid Lightning address + Address already attached to \"%1$s\" + This code or address is invalid + + Add an address for this contact + Edit Bolt12 code + Edit Lightning address for this contact. + + Label (optional) + Bolt12 code or Lightning address + Lightning address + Bolt12 offer code + + Do you want to delete this address? + Do you want to delete this contact? + + Attach a name to a Bolt12 code + Name + Enter a name + Pay with your Bolt12 key + If they know your own Bolt12 payment code, they will be able to tell when payments are from you. + Use a throwaway id when you pay this contact. + Addresses + Add Bolt12 code or Lightning address… + Add new… + + Search by name + No contacts found… + + Take a photo + Browse images + Delete picture + + + + Access control + + System authentication unavailable + No suitable authentication hardware on this device. + The biometric hardware is not available. Try again later. + Please enroll a PIN/Schema/Fingerprint in Android first. + The hardware is unsafe. An Android security update is required. + Not supported by this version of Android. + Too many attempts, try again later. + Unhandled hardware vendor error + Authentication attempt timed out + Authentication has been cancelled + This version of Android is not compatible. + Unhandled error code: %1$d + + Accessing the application + System authentication + Secures app entry behind the Android user credentials + Lock PIN + Secures app entry behind a 6-digits PIN code + + Lock timeout + After %1$s minute(s) of inactivity + Never + + Sending payments + Spending PIN + If enabled, a PIN code is required to spend funds from the wallet. + + Misc + Shuffle PIN keypad + + + + Legacy mode + Tap here for more info + How does it work? + Phoenix authenticates with a key unique to %1$s. This unique key becomes your password for your account there. + Privacy + The service will not have access to your wallet whatsoever. They cannot see your balance, payments, or keys. + Legacy mode + Phoenix uses a non-standard scheme on this service to be compatible with older versions of the app. The associated account will not be portable to other wallets. + Change scheme + Sign-in + Try again + Signing-in to\n%1$s + Authentication success. + Authentication failure: + Network error. Check your internet connection and try again. + An unknown error occurred. Try again. + + + + Default + Use a standard scheme compliant with the LNURL specifications. This is the recommended option for new wallets, and what the Phoenix iOS app uses. + Android Legacy + Use a legacy scheme to connect to accounts created with the old Phoenix Android app. + + + + Redeem + Requesting funds… + Amount must be at least %1$s. + Amount cannot exceed %1$s. + Withdrawal has failed: + + + + The service %1$s returned an error. Contact the helpdesk of this service if the problem persists.\n\nService message details : \"%2$s\" + The service %1$s returned an HTTP error (%2$s). Contact their helpdesk if needed. + The service %1$s returned a malformed message. + Could not connect to service %1$s. + This appears to be a website (not a lightning invoice):\n\n%1$s + Service %1$s doesn\'t support lightning addresses, or doesn\'t know this user. + + + + Served by + Description + Attach a message + My message + You can attach a message to the payment. This message will be sent to the recipient. + Pay + Requesting invoice… + Paying invoice… + + Amount must be at least %1$s + Amount must be at most %1$s + + Payment has failed. + The invoice returned by %1$s does not use the same chain as your wallet. + The invoice returned by %1$s is already in progress. + The invoice returned by %1$s has already been paid. + The invoice returned by %1$s has an incorrect amount. + The invoice returned by %1$s is malformed. + + + + Display options + Filter by name + Bitcoin unit + Satoshi + 1 sat is 0.00000001 btc + Bit + 1 bit is 0.000001 btc + Milli-Bitcoin + 1 mbtc is 0.001 btc + Bitcoin + Fiat currency + Application theme + Dark theme + Light theme + Follow system + Application language + + + + Electrum server + To secure your payment channels Phoenix monitors the Bitcoin blockchain through Electrum servers.\n\nBy default, random servers are used. You can also configure Phoenix to connect only to your own server. + Block height + Use the TLS port (default 50002). + For onion services, use the plain TCP port, not the TLS one. + Since you\'ve enabled Tor, you should use an onion address for this server. + No, I don\'t want to use an onion address + + Disconnected from Electrum + Disconnected from %1$s + Connecting to (random) %1$s + Connecting to %1$s + Connected to %1$s + + You are using a custom server + This server provided an unknown certificate. Connection is rejected. + Tor is enabled. This server should use an onion address. + + Use a custom server + Server address (host:port) + This address is invalid. + Connect + Checking certificate… + Failed to connect + This address cannot be resolved. + Untrusted certificate + SHA1 Fingerprint + SHA256 Fingerprint + Issuer + Subject + Valid until + Copy certificate + Trust certificate + + + + Tor + Enable Tor + How it works + + + + Connections status + Some connections are not established yet. The app will not function correctly until they are. + Your device has no Internet connection. The app will not function properly.\n\nPlease check your device\'s setting. + Electrum + Peer + Manage connection for %1$s + Connecting… + Connected + Disconnected + Bad certificate! + Invalid address! + Tor is enabled! + Make sure your Tor VPN is active and running. + + + + About Phoenix + Phoenix version: %1$s + Any questions? Check the FAQ + Support + Privacy + Terms + + + + Payment options + + Incoming payments + Outgoing payments + LNURL + + Invoice description + No description set… + Default description + Your invoices will use this description by default. You can override it on a case-by-case basis. + Invoice description + + Invoice expiry + Invoice expiry + Invoices that you create are stale after this delay. Default value is 1 week. + 1 hour + 1 day + 1 week (default) + 2 weeks + 3 weeks + %1$s seconds + + LNURL authentication scheme + + Bitcoin address format + Legacy + A less efficient and less private format that does not rotate addresses. However, it is compatible with almost every services and wallets. + Taproot (recommended) + Default format, with better privacy, cheaper fees and address rotation. Some services or wallets may however not understand the address. + + Enable overpayment + You\'ll be able to overpay Lightning invoices up to 2 times the amount requested. Useful for manual tipping, or as a privacy measure. + Disabled (default) + + + + Argentine Peso (official rate) + Argentine Peso + Cuban Peso (official rate) + Cuban Peso + Lebanese Pound (official rate) + Lebanese Pound + + + + Local payments + Export + No payments yet… + Today + Yesterday + Earlier this week + Last week + + Export payments + CSV export + Export your local successful payments in CSV format. Useful for accounting purposes. + Start date + End date + Include origin/destination + Include description + Export + No successful payments yet + Please pick a valid start/end date + Exporting payments… + Copy data to clipboard… + Phoenix - payments from %1$s to %2$s + Share Phoenix payments… + Share file + Export failed + No payments found. + + Database export + Encrypt and export your payments database. This can be used to migrate your payments history from this device to another. + Export database + Export has failed + The file can be found in your device\'s public folders. + + + + (inclusive) + + + + Wallet info + Legacy descriptor + Descriptor + User public key + Swap addresses + Master public key + (Path: %1$s) + + Ready for swap + Waiting for %1$d confirmations + +%1$d more… + Confirmed balance + Unconfirmed balance + +%1$s incoming + Loading wallet data… + + Swap-in addresses + Synchronizing… + Taproot + Legacy + + Lightning + Node id + Show legacy node id + Legacy node id + + Final wallet + Spend + + + + Channel management + Retrieving feerate… + My fee setting + Advanced channels management + Retrieving policy… + + + + Feerate + %1$s sat/vbyte + + Prepare payment + Estimating fees… + You will pay %1$s to the Bitcoin miners + Execute payment + Executing payment… + Payment complete + Payment failed + Cannot proceed + + + + Unknown mempool state + Phoenix was unable to retrieve the current state of the mempool and cannot estimate the speed of your transactions.\n\nCheck the mempool manually on an explorer, and use an adequate value! + ≈ 10 minutes + ≈ 30 minutes` + ≈ 1 hour + Low feerate + + + + You don\'t have any channels + Aborted by peer [%1$s] + Unable to create a new commitment + There\'s another splice in progress + Aborted due to an error + Channel is disconnected + Funding has failed [%1$s] + Not enough funds + Cannot start transaction session with the peer + Interactive tx session failed [%1$s] + Invalid splice-out pubkey script + A splice payment is already in progress + Invalid liquidity-ads request: [%1$s] + Invalid channel parameters: [%1$s] + Unexpected error: [%1$s] + + + + Delete wallet + This screens allows you delete this wallet from your device. + All data for this wallet will be deleted. This includes your payments history. + Save payments history + Review + + Confirm wallet deletion + The wallet will be completely deleted from this device. + This wallet\'s seed and its payments history will be deleted from the disk. You will be prompted to use another wallet, or create a new one. + Other wallets that you have already imported will not be deleted. + Don\'t lose your funds + I understand that if I lose the recovery phrase after deleting the wallet, any remaining funds would be permanently lost. + Delete wallet + Shutting down… + Deleting preferences… + Deleting seed… + Deleting databases… + The wallet has been successfully reset. + Reset failed + + + + Swap-in signer + This debugging tool lets you sign swap-in inputs. Only use if you understand what it does. + Unsigned tx + Server nonce + Sign + Signing… + User signature + Invalid unsigned transaction + Check that the input is complete and not missing any character. + Failed to sign input + + + + This screen allows you to link a Bolt12 code or a Lightning address to a name.\n\nThese contacts data are specific to Phoenix and stored locally. + + + + Experimental features + + Bip353 DNS address + No address yet… + Claim my address + Claiming address… + Failed to claim address + + + + Enter Lock PIN + Enter Spending PIN + Enter Spending PIN to view the seed] + Enter PIN to continue + Create Lock PIN + Create Spending PIN + Confirm PIN + + Checking PIN… + Incorrect + Locked for %1$s + + An error occurred + Malformed PIN + PIN mismatch! + Error when saving PIN + + + + APDU service for Phoenix to emulate a NFC tag + AID for the NFC tag emulated by Phoenix + + NFC is busy + NFC is not available + NFC is disabled + Tag emulation is not supported + + Nfc + Hold near the NFC reader + Ready to scan + Hold near the NFC device to read it + + + + Currency Converter + Done + Enter amount in %1$s + Add new currency… + Last refreshed: %1$s + Other… + Select a currency + No match found… + + + + Wallet + Add new wallet + Lock + + + Enter a name + Default Wallet + If a default wallet is selected, it will be automatically opened on app launch. + Hidden Wallet (WIP) + Wallet will not be visible in selector screens. To access the wallet, you must enter its lock PIN. + Pick an avatar + + + + I understand. + + + + Phoenix is running in the background + %1$s Received %2$s + + Please start wallet + An incoming settlement is pending. + + A payment is pending + Start Phoenix so the payment can be finalised in due course. + + Missed incoming payment + Phoenix was unable to start in the background. + + On-chain deposit pending (+%1$s) + Payment rejected (+%1$s) + Automated channel management is disabled. Tap for details. + Automated channel management is disabled. This deposit will expire by %1$s. + The fee was %1$s, but your max fee was set to %2$s. Tap for details. + The fee was %1$s, but your max fee was set to %2$s. This deposit will expire by %3$s. + The fee was %1$s which is more than %2$s%% of the amount received. Tap for details. + The fee was %1$s which is more than %2$s%% of the amount received. This deposit will expire by %3$s. + Payment amount is too low. + An error occurred during funding. Please try again later. + + Please start Phoenix + Some of your channels may have closed. + + + + General + Fees + Privacy & Security + Advanced + Danger zone + + Display + Wallet info + Channel management + Recovery phrase + Access control + Payment channels + Logs + Electrum server + Delete wallet + Close channels + Force-close channels + Tor + About + Payment options + Payment history + Notifications + Contacts + Currency converter + Add liquidity + + + + Confirming + Waiting for confirmation first before they can be swapped to Lightning. + + Waiting for swap + Will deploy to Lightning when mining fees are below %1$s. + Will remain on-chain because automated channels management is disabled. + Will deploy to Lightning when conditions apply. + Attention! Some funds will expire soon and won\'t be eligible for a swap anymore. + + Expired + Cannot be swapped anymore, after 4 months waiting. These funds must be spent manually. + + Final wallet + These funds come from closed Lightning channels. They must be spent manually. + On-chain balance + + Background processing restricted + Phoenix may not be able to receive payments when it is in the background, or when it is closed. + This happens because: + The device is in power saving mode + FCM notifications unavailable + If you\'re on GrapheneOS or CalyxOS, install Google Play Services to get FCM notifications. Check the FAQ for guidance. + + + + Settings + Send + Receive + + + + Create new wallet + Restore my wallet + + Phoenix is only on Lightning + Phoenix will only display funds that have already been managed by Phoenix. Funds attached to a seed generated by another application will not appear here (this includes on-chain funds). + Beware of using the same seed in parallel + Do not use the same seed simultaneously on different devices. This can cause conflicts between the two instances of Phoenix, and result in Lightning channels being closed. + + Your wallet\'s seed is a list of 12 English words. Type-in each word of this list in the box below. + Enter word #%1$s + This is not a valid word. + This seed is valid + You can now proceed and restore your wallet + This seed is not valid + Make sure you entered the correct words in the right order. + + Import payments history + Optional. Use this button to restore a payments database file from another device. + Will restore payments history + Use another file + Cannot restore payments + Try again + The file cannot be decrypted. Make sure you are using a Phoenix database file (not a CSV), and that this file matches the wallet you\'re restoring. + This file cannot be opened. Try again. + This file could not be written to the application\'s data folder. Try again. + Restore wallet + + + + Update required + This version of Phoenix (v%1$s) is not compatible with your wallet. Please update, or use a compatible version. + Update on Google Play + + + + Payment will fail + On-chain fee expected + Tap to know more + + Dismiss + Enable automated channels + Configure fee limit + + An on-chain operation will be likely required for you to receive this amount.\n\nThe fee is estimated to be around %1$s. + An on-chain operation will likely be required for you to receive this amount. + + A fee of %1$s is expected to receive this amount, and that fee is above your limit of %2$s.\n\nIncrease this limit in the channel management settings. + A fee of %1$s is expected to receive this amount, and that fee is above your limit of %2$s.\n\nIncrease this limit in the channel management settings, or request additional liquidity. + A fee of %1$s is expected to receive this amount, and that fee is more than %2$s%% of the amount.\n\nIncrease this limit in the channel management settings. + A fee of %1$s is expected to receive this amount, and that fee is more than %2$s%% of the amount.\n\nIncrease this limit in the channel management settings, or request additional liquidity. + + Inbound liquidity is insufficient for this amount, and you have disabled automated channel management. + + + + Tor is enabled + Phoenix may have issues receiving payments. Make sure the app stays open in the foreground and that connection is stable. + + + + This invoice is expired. + This payment is already being processed. Please wait for it to complete. + This payment has already been paid. + This payment does not use the same blockchain as your wallet. + Failed to process this LNURL link. Make sure it is valid. + This type of LNURL is not supported yet. + This is not a supported payment request. + Unable to retrieve data for this address. You may be experiencing a connectivity issue. + Name \"%1$s\" is not found on \"%2$s\". + This address uses an invalid Bip21 resource. + This address uses an invalid Bolt12 offer. + This address is hosted on an unsecure DNS. DNSSEC must be enabled. + + + + Must be at least %1$s + Must be no more than %1$s + + + + Bitcoin address + Send all funds to a Bitcoin address. All payments channels will be closed. + Confirm closing + All the funds will be sent to: + Miner fees estimated to: + Fee cost could not be estimated. + + Force-close channels + This screen allows you to unilaterally close your channels.\n\nThis is not a \"fix-everything\" magic button: it is here as a safety measure and should only be used in extreme scenarios. For example, if your peer (ACINQ) disappears permanently, preventing you from spending your money. In all other cases, if you experience issues with Phoenix you should contact support.\n\nForce closing channels will cost you money (to cover the on-chain fees) and will cause your funds to be locked for days.\n\nDo not uninstall the app until your channels are fully closed, or you will lose money.\n\nDo not use this feature if you don\'t fully understand what it does. + Funds will eventually be sent to the final wallet: + + Confirm force-closing + All the funds will be sent to your final wallet, after a significant delay. + Force-close all my channels + + + + Backup your wallet to prevent losing your bitcoins. + Backup my wallet + + Enable Android notifications + Notifications are disabled in Android settings. Phoenix won\'t be able to notify you when a payment is processing. + Enable + + A deposit will expire soon. + View details + + Phoenix regularly monitors the blockchain when in the background, but was unable to do so the last few days.\n\nMake sure Android does not block Phoenix, and that it can connect to Electrum. + Dismiss + + An update is available + A critical update is available. You should update Phoenix as soon as possible. + Update on Google Play + + On-chain fees are high. + See how Phoenix is affected + + Cannot access the Tor network. Phoenix will not function correctly. + Fix it + + On-chain funds pending (+%1$s) + An incoming payment has been recently rejected + %1$d incoming payments recently rejected + Payment rejected (+%1$s) + Automated channel management is disabled. + The fee was %1$s, but your max fee was set to %2$s. + The fee was %1$s which is more than %2$s%% of the amount. + Tap to configure. + View details + + Watchtower report + %1$d channel was successfully checked on %2$s. No issues were found. + %1$d channels were successfully checked on %2$s. No issues were found. + Watchtower alert + Revoked commitments were found on %1$s for channel(s): %2$s. This channel may be closed. + + + + The recovery phrase (sometimes called a seed), is a list of 12 English words. It allows you to recover full access to your funds if needed.\n\nOnly you alone possess this seed. Keep it private.\n\nDo not share this seed with anyone.\nBeware of phishing. The developers of Phoenix will never ask for your seed.\n\nDo not lose this seed.\nSave it somewhere safe (not on this phone). If you lose your seed and your phone, you\'ve lost your funds. + Display seed + KEEP THIS SEED SAFE.\nDO NOT SHARE. + You have not backed up your recovery phrase! + If you do not back it up and you lose access to Phoenix, you will lose your funds! + I have saved my recovery phrase somewhere safe. + I understand that if I lose my phone and my recovery phrase, then I will lose the funds in my wallet. + + + + Miner fees + Fees paid to the Bitcoin network miners to process the on-chain transaction. + Service fees + Fees paid for the creation of a new payment channel. This is not always required. + + Liquidity + Service fees + Fees paid for the liquidity service. + Miner fees + Fees paid to the Bitcoin network miners to process the on-chain transaction. + Caused by + This liquidity was required to receive a payment. + See how to optimise + + + + You can anonymously sign-in and authorize an action on: + You can redeem funds from\n%1$s. + The withdrawal request has been sent to %1$s.\n\nIt may take some time before they send the funds. Please keep the app online in the meanwhile. + + + + Enabling Tor + This requires installing a third-party Tor Proxy VPN app such as Orbot. + Tor can improve privacy, but may cause performance issues and missed payments. + Disabling Tor + If you disable this option, your IP address may be revealed to various service providers. + Are you sure you want to proceed ? + Processing changes… + + No access to the Tor network + Fix it + Phoenix needs access to Tor to function properly. + Make sure your Tor Proxy VPN app is up and running, and that it\'s connected to Tor. + If you don\'t have a Tor VPN app, install one. We recommend Orbot. + Open Tor settings + Open Orbot page + + + + Phoenix is a Bitcoin wallet using the Lightning network for sending and receiving payments.\n\nIt is a free open source software, developed by ACINQ under the Apache 2.0 License. + Safeguarding your key + This wallet is self-custodial: you have sole custody of the wallet\'s 12-words seed key.\n\nThis key gives access to your money. Do not reveal it to anyone, and beware of phishing. + Exchange rates + Bitcoin/fiat exchange rates are retrieved from various third-party APIs:\n\n- Blockchain.info\n- Coinbase.com\n- Bluelytics.com.ar\n- Yadio.io\n\nThose rates may not be accurate. Always check the actual Bitcoin amount before sending a payment. + + + + Welcome! + With Phoenix, sending and receiving bitcoins is easy and safe. + Next + + Bitcoin supercharged + Phoenix uses payment channels to make Bitcoin fast and private. + Next + + Your key, your bitcoins + Phoenix is self-custodial. You take control. + You can restore your wallet at anytime using your secret key. Keep it safe! + Get started + + + + Swap-in wallet + The swap-in wallet manages on-chain funds deposited to Phoenix.\n\nIt swaps them automatically to Lightning when possible, according to your channels management setting. + See how it works + Tap to configure + There are no swaps in progress. + + On-chain funds are automatically swapped into Lightning if the fee paid to miners is less than %1$s (can be configured). + Funds not swapped after %1$d months are recoverable on-chain. + Automated channels management is disabled. No swap will occur, funds will remain on-chain. + + A swap attempt failed %1$s + Channels management was disabled. + This swap will expire in a day! + This swap will expire in %1$s days. + + Timed out + These funds will be available from %1$s days onwards. + + Cancelled funds + These funds were not swapped in time. Tap to spend. + + The final wallet is where funds are sent when your Lightning channels are closed or when there is a problem. It usually should be empty. + + + + Incoming payments sometimes require on-chain transactions. This does not always happen, only when needed. + Fees are currently estimated at around %1$s (≈%2$s). + Automated channel management + Incoming payments that require on-chain operations will be rejected. + + Max fee amount + Payments whose fees exceed that value will be rejected. + This value is too low. + Below the expected fee. Some payments may be rejected. + + Advanced options + Channel management is disabled. It can be enabled in the Channel management setting screen. + Attention! + This screen is for advanced users. Do not change these settings unless you understand their purposes. + + Additional verifications + Percentage check + Check the fee relative to the amount received. This option is useful as a sanity check for small payments. + Policy overrides + Skip absolute fee check for Lightning + When enabled, incoming Lightning payments will ignore the absolute max fee limit. Only the percentage check will apply.\n\nAttention: if the Bitcoin mempool feerate is high, incoming LN payments requiring an on-chain operation could be expensive. + Save policy + Request inbound liquidity + + + Phoenix allows you to receive payments on Bitcoin\'s blockchain (L1) and Bitcoin\'s Lightning layer (L2). + \n\n + - the blockchain layer (L1) is slower, and generally much more expensive (requires miner fees) + \n + - the Lightning layer (L2) is much faster, and generally much cheaper (especially for smaller payments) + \n\n + When you receive a payment on L1, Phoenix will automatically move the funds to L2 IF the miner fees adhere to your configured fee policy. + \n\n + Payments you receive on L2 can be received instantly and for zero fees. However, occasionally an L1 operation is also required in order to manage the L2 payment channel. This can be done automatically IF the miner fees adhere to your configured fee policy. + + + + + No channels yet! + You first need funds in the wallet to use this feature. + Plan ahead your liquidity + Inbound liquidity lets you avoid on-chain transaction fees for future payments received over Lightning.\n\nBy requesting more liquidity now, you can save fees later. + More info + Current liquidity + + Request liquidity + Estimate liquidity cost + Estimating cost… + Miner fee + Mining fee contribution for the underlying on-chain transaction. + Service fee + This fee goes to the service providing the liquidity. + Duration + 1 year + As you receive funds, liquidity will be consumed and become your balance. After one year, the remaining unused liquidity will be reclaimed by the service. + The total fee is more than 25% of the liquidity amount requested. + The total fees exceed your balance. + + Review + You are requesting an initial amount of liquidity. Liquidity is not constant over time: as you receive funds over Lightning, the liquidity will be consumed and become your balance. + After one year, the remaining unused liquidity will be reclaimed by the service. + + Accept + Processing splice… + + Liquidity successfully added! + Amount added: %1$s + + Liquidity request has failed + Channels are not available. Try again later. + The requested amount is invalid. + + + + Unconfirmed - tap to accelerate + + + + Accelerate my transactions. + You can make all your unconfirmed transactions use a higher feerate to encourage miners to favour your payments. + This feerate is less than what your unconfirmed transactions are already using. Use a higher feerate. + + + + Channels are closing. + Channels are already processing a splice. Try again later. + Fee is insufficient. + This payment exceeds your balance. + The payment amount is too big - try splitting it in several parts. + The payment amount is too small. + The expiry of this payment is too far in the future. + The payment was rejected by the recipient. This particular invoice may have already been paid. + The recipient is offline. + The payment could not be relayed to the recipient (probably insufficient inbound liquidity). + An error occurred on a node in the payment route. The payment may succeed if you try again. + You have too many pending payments. Try again once they are settled. + + The ID of the payment is not valid. Try again. + This invoice has already been paid. + Your channel is not connected yet. Wait for a stable connection and try again. + Your channel is still in the process of being opened. Wait and try again. + This invoice uses unsupported features. Make sure you\'re on the latest Phoenix version. + The payment amount is invalid. + The payment could not be sent through your existing channels. + Recipient is not reachable, or does not have enough inbound liquidity. + An unknown error occurred and payment has failed. + The wallet was restarted while the payment was processing. + + + + Low feerate! + Transactions with insufficient feerate may linger for days or weeks without confirming. + Choosing the feerate is your responsibility. Once sent, this transaction cannot be cancelled, only accelerated with higher fees. + Are you sure you want to proceed? + + + + Channel size impacted + Funds sent on-chain are taken from your side of the channel, reducing the channel size by the same amount. Your inbound liquidity remains unchanged. + Don\'t show this message again. + + + + Spend cancelled swap-ins + No cancelled swap-ins yet. + Available: %1$s (%2$s) + Use this screen to spend on-chain deposits that were not swapped in time. This does not affect your Lightning channels. + Make sure the destination address is valid, and use a reasonable feerate. + Estimate fees + Estimating fees… + Broadcast + Broadcasting… + Transaction error. + This address is not valid. + Cannot create the refund transaction. + Transaction published. + You can find the transaction below. It will not appear in your payments history, so make a copy of its ID now if needed. + + + + Spend funds from final wallet + No funds available + Amount available + Use this screen to spend funds from your final wallet. These funds come from channels that have been closed in the past. This does not affect your existing Lightning channels. + + + + This is a human-readable address for your Bolt12 payment request. + Want a prettier address? Use third-party services, or self-host the address! \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 00000000..bb7d28ba --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,1497 @@ + + Machankura + + Channels watcher + Shows up when you need to start Phoenix. + + Payment finalisation + Tells you when Phoenix needs to be started to settle a pending payment. + + Payment rejected + Shows up when Phoenix cannot receive a payment because of a liquidity issue. + + Payment received + Shows up when you receive a payment while the app is in the background. + + Running in the background + Tells you when Phoenix is running in the background. + + Swap timeout + Tells you when a swap is going to timeout. + + + + Creating your wallet… + Wallet creation failed + This seed has already been imported + The wallet could not be written at this time. Try again later. + + Restore my wallet + Next + Restoring your wallet… + Use a custom Electrum server + + + + Etiam porttitor egestas faucibus. Curabitur condimentum eros non ipsum elementum egestas. + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean ut facilisis lectus. Integer massa tellus, suscipit sit amet felis vitae, blandit consectetur dolor. Fusce volutpat id magna id vestibulum. Integer a erat lacinia, placerat risus a, fermentum justo. Etiam euismod tincidunt dolor vel posuere. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Curabitur non euismod dui. Morbi enim dui, blandit sed erat sit amet, porta pulvinar odio. Cras metus felis, vestibulum eu consequat vitae, consectetur quis nulla. Fusce vulputate, elit et luctus sodales, metus metus elementum sem, eget commodo nunc nunc in ex. Sed aliquam eros nibh, ac volutpat turpis accumsan vitae. Cras suscipit ipsum accumsan aliquam interdum. + + Praesent ut nisi fringilla, pharetra dui sit amet, ornare urna. Donec at ultrices nunc. Fusce gravida metus vitae viverra egestas. In hac habitasse platea dictumst. Proin consequat fringilla felis, vehicula vehicula turpis ullamcorper nec. Pellentesque urna massa, blandit cursus metus et, ultrices consectetur neque. Suspendisse hendrerit venenatis mi ac tincidunt. Morbi hendrerit orci vitae erat luctus, at dignissim turpis accumsan. Integer elementum est eu tincidunt ullamcorper. Phasellus varius porttitor vestibulum. Maecenas faucibus ullamcorper diam, ac commodo dui fringilla sed. Aliquam arcu velit, porta eu sem vel, rhoncus bibendum dolor. + \nEtiam porttitor egestas faucibus. Curabitur condimentum eros non ipsum elementum egestas. Nam tempor euismod erat eget scelerisque. Integer sit amet laoreet erat. Duis enim turpis, vehicula eu justo vitae, dapibus auctor mauris. Aenean euismod eleifend dui a aliquet. Aliquam eleifend malesuada tortor ornare volutpat. In augue tortor, gravida et volutpat elementum, iaculis non sapien. Etiam dolor nisi, pulvinar a rhoncus ac, eleifend ac libero. In lobortis enim vitae ultricies viverra. Maecenas accumsan elementum sem, nec pharetra urna maximus maximus. + \nIn sit amet volutpat ligula, ac pretium dolor. Phasellus posuere rhoncus magna quis fermentum. Ut risus turpis, fermentum facilisis mollis in, porttitor eget erat. Donec luctus egestas ligula et interdum. Phasellus vitae hendrerit sem, at vehicula nulla. Curabitur mollis risus quis metus euismod ullamcorper. Nam eu aliquet mi. Duis id urna ac urna iaculis blandit. Morbi eros dui, congue a posuere efficitur, imperdiet a nisi. Morbi non orci non lorem aliquet tincidunt. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce pulvinar, mi vitae sollicitudin dignissim, nunc urna facilisis massa, ut scelerisque mi felis in sapien. + \nNam felis felix, tristique commodo odio eget, imperdiet viverra erat. Donec venenatis magna pulvinar, finibus leo id, gravida augue. Integer ante leo, bibendum ac nibh quis, auctor commodo quam. Sed luctus vitae quam vel condimentum. Mauris eu rhoncus mauris. Fusce enim diam, consequat a odio sit amet, accumsan cursus nisl. Etiam lectus nunc, lacinia id purus sit amet, pulvinar auctor odio. Maecenas vitae arcu sit amet est cursus maximus. Nullam ac sapien non nibh tempor rhoncus. Mauris dignissim cursus libero quis egestas. + + + + + Unlock to continue + PIN code + System lock + + + + Initialising… + Preparing wallet… + Decrypting… + Starting wallet… + Opening wallet… + Select a wallet + + Could not start the wallet + Unable to read wallet data. + Try again + Unhandled file serialisation + Decryption failure:\n%1$s + Android keystore failure:\n%1$s + Manual recover + + + + Wallet recovery + This screen lets you manually recover a single wallet by entering its 12-words recovery phrase.\n\nWords must be entered in the correct order, and separated with a single space. + Enter word #%1$d + No more than 12 words! + This seed is not valid + + Import seed + Try again + Checking seed… + + An error occurred. + This seed does not match any existing wallet data. + Failed to perform keystore operations. + Recovering wallet… + + + + - + + + Waiting for confirmation + Payment pending + Payment confirmed + Payment complete + Payment has failed + + + + QR Code of the invoice/address + + Address + Synchronizing address… + Bitcoin address + Share this Bitcoin address with… + + Generating… + Single use + Bolt11 + Reusable + Bolt12 + Amount + Description + Could not generate invoice + Lightning payment + Share this Lightning payment code with… + + Bitcoin address + This QR code is a classic Bitcoin address.\n\nIt can be read by almost any Bitcoin service/wallet, but payments will be slower to arrive. + Lightning address + Lightning + This QR code is a Lightning invoice.\n\nLightning payments are very fast and usually cheaper, but some wallets and services may not support them yet.\n\nIn that case, swipe to the left to get a regular Bitcoin address. + Lightning Bolt11 + Lightning Bolt12 + Bitcoin URI + + What is this? + This Lightning address uses the modern Bip353 standard that works with Bolt12 payment requests.\n\nIt is more private than LNURL-based Lightning addresses, and can even be self-hosted.\n\nHowever, it\'s is bleeding edge tech ; some wallets or services do not understand it yet and won\'t be able to pay you. + Learn more. + + Customise this Bolt11 invoice + Customise this Bolt12 invoice + Amount (optional) + Amount to receive + Description (optional) + Enter a description for this invoice + Generate + + + + Balance + Amount is too large. + Amount cannot be negative. + This is not a valid amount. + This amount exceeds your balance. + Cannot pay more than %1$s. + This amount is below the requested amount of %1$s. + Send to + Description + Fee + N/A + Loading fee… + Amountless invoice + The invoice for this payment does not request a specific amount. This may be exploited by malicious nodes during the payment.\n\nTo be safe, ask the recipient to specify an amount when generating the invoice. + Waiting for channels… + Pay + Confirm & Pay + Try again + + Message + Tap to attach a message… + Attach a custom message + The recipient will see this message + Enter a message… + Fetching payment details… + Payment has failed + Could not retrieve payment details within a reasonable time.\n\nThe recipient may be offline or unreachable. + + Pay on-chain + On-chain transactions are typically slower and best suited for large payments. + Pay with Lightning + Lightning payments are fast and best suited for small payments. + + Address + Fee rate + Retrieving current feerate… + + Please enter a valid feerate. + Total exceeds your balance + Payment has failed + + Prepare transaction + Preparing transaction… + Executing splice… + Miner fee + Uses an effective feerate of %1$s sat/vbyte. + Total + + + + Tap to grant camera permission + Camera permission has been denied + Zoom or tap the QR to focus + + + + Send + No contacts yet… + No matches for search… + satoshi@domain, bc1q..., lnbc... + + Paste + Scan QR code + Choose image + This image could not be processed. + No QR code found in this image. + Reading input… + Fetching data from service… + Resolving payment request over DNS… + + You have on-chain funds on the final wallet, but none on Lightning. + View final wallet + + + + + ≈ %1$s + !? + Exchange rate unavailable + + just now + N/A + Processing data… + Loading data… + Loading preferences… + Copied to clipboard! + + ₿%1$s + + Open link in a browser + Open transaction in an explorer + Open address in an explorer + This field cannot be blank + Please enter a valid amount + Please enter a valid number + This field must be an integer + + Go back + Next + Copy + Share + OK + Save + Delete + Confirm + Cancel + Close + + + + Drain my wallet + Loading… + Checking balance… + Balance: %1$s (≈ %2$s) + The wallet does not have any channels that are eligible for closing. + Review closing + This address uses a different blockchain + This address uses unsupported features + This address is not supported + Closing has been initiated. The closing transaction is in your transactions list. + + No channels eligible for closing. + + + + Payment channels + Import channels + Spend channel address + Overview + Balance + Balance is the aggregated balance of your active channels. It\'s what you can spend over Lightning. + Inbound liquidity + Inbound liquidity is what your channels can receive over Lightning without having to go on-chain and pay fees. + Loading channel data… + You don\'t have any channels yet.\n\nA new payment channel will be created automatically when needed. + + + + Channel details + No active channel exists for that identifier. + Channel id + State + Balance + Inbound liquidity + + Active commitments + Inactive commitments + Funding tx: + Balance: + Capacity: + Triggered by: + + Display raw data + Share + Channels data + Share channel data + + + + Import raw channel data + This screen is a debugging tool that can be used to manually import encrypted channels data.\n\nUse with caution. + Data blob + Import + Importing data… + Import successful + You must now restart Phoenix. + Import has failed + Data are malformed. A encrypted hex blob is expected. + Data could not be decrypted by this wallet. + Version %1$d is not supported + + + + Spend channel address + This screen is a debugging tool that helps recover funds that have been accidentally sent to a channel\'s outpoint. + Amount + Tx index + Raw channel data + Remote funding pubkey + Unsigned tx + Sign + Signing… + + Signature successfully generated. + Public key + Signature + + Failed to sign data + Invalid amount + Invalid tx index + Malformed channel data + Cannot decrypt channel data + Unhandled channel state [%1$s] + Malformed channel version [%1$s] + Malformed remote funding pubkey [%1$s] + Malformed unsigned tx [%1$s] + Malformed remote funding pubkey [%1$s] + Malformed transaction [%1$s] + Invalid signature + + + + Loading… + +%1$s + + FAQ + Use the Receive and Send buttons at the bottom of this screen to get started! + Show all payments… + Desync! + Certificate + Invalid address + Connecting… + Tor + Request liquidity + You currently have %1$d payment(s) pending in your wallet.\n\nKeep the app open to make sure these payments settle properly without issues. + + + + Notifications + Important messages + Recent activity + No notifications yet + + + + Recovery phrase + Unlocking seed… + Could not unlock seed + BIP39 seed with standard BIP84 derivation path + Loading preferences… + Backup confirmation + + + + Application logs + Exporting logs… + Error: logs could not be exported + View logs… + View logs with… + Share logs… + Phoenix App logs + Share Phoenix logs… + + + + Loading payment details… + Could not find payment details + + LIQUIDITY ADDED %1$s + CHANNEL MANAGEMENT %1$s + COMPLETE %1$s + SENT %1$s + Pending… + FAILED\nNo money has been sent. + + Not received yet. + Waiting for channel to open + RECEIVED %1$s + + Waiting for confirmations + Fetching status… + 0 confirmation + Tap to accelerate + %1$d confirmation(s) + Confirmed on-chain + + Message + Sent by + Unknown + Be careful with messages from unknown sources + + Serviced by + Message + Link + Open link + Message + Decrypting message… + + Description + Note + Sent to + Bitcoin miners + Fees + Error + No description + This payment happened following a conflict in a channel. + + On-chain payment + Closing channel + Migration from legacy app + Bump transactions + Manual liquidity (+%1$s) + Channel management + Swap-out to %1$s + On-chain deposit + + to %1$s + from %1$s + + Add a custom description to this payment + Description + Attach note + Edit note + Technical details + + + + Technical details + Type of payment + Channel closing + Incoming on-chain payment (splice) + Incoming on-chain payment (new channel) + Incoming payment (legacy pay-to-open) + Incoming on-chain payment (legacy swap-in) + Incoming Lightning payment (bolt11) + Incoming Lightning payment (bolt12) + Outgoing Lightning payment (bolt11) + Outgoing Lightning payment (bolt12) + Outgoing on-chain payment + Outgoing on-chain payment (legacy swap) + Accelerate on-chain transactions + Inbound liquidity request (manual) + Inbound liquidity request (auto) + + Deposit address + Bitcoin address + + Spliced channel + Local inputs + - #%1$s: + + Liquidity requested + + Closing type + Mutual + Local + Remote + Revoked + Other + + Target public key + Payment Hash + Preimage + Cryptographic proof that the recipient successfully received the payment. + Bolt11 invoice + Invoice description + Bolt12 invoice + Offer + Metadata + Payer key + Your offer key + Purchase type + + Payment status + Successful + Confirming + Pending + Failed + + Payment parts (%1$d) + Part + Hops + + Received via + Channel operation (new or splice-in) + Lightning payment + Fee credit + Channel id + Transaction + + Created at + Completed at + Elapsed + %1$s ms + + Amount requested + Amount sent (fees included) + Amount received + Fee credit accrued + Amount added to fee credit + ≈ %1$s (now) + ≈ %1$s (then) + + + + Add contact + Send a payment + + Create new contact… + + Name cannot be left empty + Invalid Bolt12 code + Code already attached to \"%1$s\" + Invalid Lightning address + Address already attached to \"%1$s\" + This code or address is invalid + + Add an address for this contact + Edit Bolt12 code + Edit Lightning address for this contact. + + Label (optional) + Bolt12 code or Lightning address + Lightning address + Bolt12 offer code + + Do you want to delete this address? + Do you want to delete this contact? + + Attach a name to a Bolt12 code + Name + Enter a name + Pay with your Bolt12 key + If they know your own Bolt12 payment code, they will be able to tell when payments are from you. + Use a throwaway id when you pay this contact. + Addresses + Add Bolt12 code or Lightning address… + Add new… + + Search by name + No contacts found… + + Take a photo + Browse images + Delete picture + + + + Access control + + System authentication unavailable + No suitable authentication hardware on this device. + The biometric hardware is not available. Try again later. + Please enroll a PIN/Schema/Fingerprint in Android first. + The hardware is unsafe. An Android security update is required. + Not supported by this version of Android. + Too many attempts, try again later. + Unhandled hardware vendor error + Authentication attempt timed out + Authentication has been cancelled + This version of Android is not compatible. + Unhandled error code: %1$d + + Accessing the application + System authentication + Secures app entry behind the Android user credentials + Lock PIN + Secures app entry behind a 6-digits PIN code + + Lock timeout + After %1$s minute(s) of inactivity + Never + + Sending payments + Spending PIN + If enabled, a PIN code is required to spend funds from the wallet. + + Misc + Shuffle PIN keypad + + + + Legacy mode + Tap here for more info + How does it work? + Phoenix authenticates with a key unique to %1$s. This unique key becomes your password for your account there. + Privacy + The service will not have access to your wallet whatsoever. They cannot see your balance, payments, or keys. + Legacy mode + Phoenix uses a non-standard scheme on this service to be compatible with older versions of the app. The associated account will not be portable to other wallets. + Change scheme + Sign-in + Try again + Signing-in to\n%1$s + Authentication success. + Authentication failure: + Network error. Check your internet connection and try again. + An unknown error occurred. Try again. + + + + Default + Use a standard scheme compliant with the LNURL specifications. This is the recommended option for new wallets, and what the Phoenix iOS app uses. + Android Legacy + Use a legacy scheme to connect to accounts created with the old Phoenix Android app. + + + + Redeem + Requesting funds… + Amount must be at least %1$s. + Amount cannot exceed %1$s. + Withdrawal has failed: + + + + The service %1$s returned an error. Contact the helpdesk of this service if the problem persists.\n\nService message details : \"%2$s\" + The service %1$s returned an HTTP error (%2$s). Contact their helpdesk if needed. + The service %1$s returned a malformed message. + Could not connect to service %1$s. + This appears to be a website (not a lightning invoice):\n\n%1$s + Service %1$s doesn\'t support lightning addresses, or doesn\'t know this user. + + + + Served by + Description + Attach a message + My message + You can attach a message to the payment. This message will be sent to the recipient. + Pay + Requesting invoice… + Paying invoice… + + Amount must be at least %1$s + Amount must be at most %1$s + + Payment has failed. + The invoice returned by %1$s does not use the same chain as your wallet. + The invoice returned by %1$s is already in progress. + The invoice returned by %1$s has already been paid. + The invoice returned by %1$s has an incorrect amount. + The invoice returned by %1$s is malformed. + + + + Display options + Filter by name + Bitcoin unit + Satoshi + 1 sat is 0.00000001 btc + Bit + 1 bit is 0.000001 btc + Milli-Bitcoin + 1 mbtc is 0.001 btc + Bitcoin + Fiat currency + Application theme + Dark theme + Light theme + Follow system + Application language + + + + Electrum server + To secure your payment channels Phoenix monitors the Bitcoin blockchain through Electrum servers.\n\nBy default, random servers are used. You can also configure Phoenix to connect only to your own server. + Block height + Use the TLS port (default 50002). + For onion services, use the plain TCP port, not the TLS one. + Since you\'ve enabled Tor, you should use an onion address for this server. + No, I don\'t want to use an onion address + + Disconnected from Electrum + Disconnected from %1$s + Connecting to (random) %1$s + Connecting to %1$s + Connected to %1$s + + You are using a custom server + This server provided an unknown certificate. Connection is rejected. + Tor is enabled. This server should use an onion address. + + Use a custom server + Server address (host:port) + This address is invalid. + Connect + Checking certificate… + Failed to connect + This address cannot be resolved. + Untrusted certificate + SHA1 Fingerprint + SHA256 Fingerprint + Issuer + Subject + Valid until + Copy certificate + Trust certificate + + + + Tor + Enable Tor + How it works + + + + Connections status + Some connections are not established yet. The app will not function correctly until they are. + Your device has no Internet connection. The app will not function properly.\n\nPlease check your device\'s setting. + Electrum + Peer + Manage connection for %1$s + Connecting… + Connected + Disconnected + Bad certificate! + Invalid address! + Tor is enabled! + Make sure your Tor VPN is active and running. + + + + About Phoenix + Phoenix version: %1$s + Any questions? Check the FAQ + Support + Privacy + Terms + + + + Payment options + + Incoming payments + Outgoing payments + LNURL + + Invoice description + No description set… + Default description + Your invoices will use this description by default. You can override it on a case-by-case basis. + Invoice description + + Invoice expiry + Invoice expiry + Invoices that you create are stale after this delay. Default value is 1 week. + 1 hour + 1 day + 1 week (default) + 2 weeks + 3 weeks + %1$s seconds + + LNURL authentication scheme + + Bitcoin address format + Legacy + A less efficient and less private format that does not rotate addresses. However, it is compatible with almost every services and wallets. + Taproot (recommended) + Default format, with better privacy, cheaper fees and address rotation. Some services or wallets may however not understand the address. + + Enable overpayment + You\'ll be able to overpay Lightning invoices up to 2 times the amount requested. Useful for manual tipping, or as a privacy measure. + Disabled (default) + + + + Argentine Peso (official rate) + Argentine Peso + Cuban Peso (official rate) + Cuban Peso + Lebanese Pound (official rate) + Lebanese Pound + + + + Local payments + Export + No payments yet… + Today + Yesterday + Earlier this week + Last week + + Export payments + CSV export + Export your local successful payments in CSV format. Useful for accounting purposes. + Start date + End date + Include origin/destination + Include description + Export + No successful payments yet + Please pick a valid start/end date + Exporting payments… + Copy data to clipboard… + Phoenix - payments from %1$s to %2$s + Share Phoenix payments… + Share file + Export failed + No payments found. + + Database export + Encrypt and export your payments database. This can be used to migrate your payments history from this device to another. + Export database + Export has failed + The file can be found in your device\'s public folders. + + + + (inclusive) + + + + Wallet info + Legacy descriptor + Descriptor + User public key + Swap addresses + Master public key + (Path: %1$s) + + Ready for swap + Waiting for %1$d confirmations + +%1$d more… + Confirmed balance + Unconfirmed balance + +%1$s incoming + Loading wallet data… + + Swap-in addresses + Synchronizing… + Taproot + Legacy + + Lightning + Node id + Show legacy node id + Legacy node id + + Final wallet + Spend + + + + Channel management + Retrieving feerate… + My fee setting + Advanced channels management + Retrieving policy… + + + + Feerate + %1$s sat/vbyte + + Prepare payment + Estimating fees… + You will pay %1$s to the Bitcoin miners + Execute payment + Executing payment… + Payment complete + Payment failed + Cannot proceed + + + + Unknown mempool state + Phoenix was unable to retrieve the current state of the mempool and cannot estimate the speed of your transactions.\n\nCheck the mempool manually on an explorer, and use an adequate value! + ≈ 10 minutes + ≈ 30 minutes` + ≈ 1 hour + Low feerate + + + + You don\'t have any channels + Aborted by peer [%1$s] + Unable to create a new commitment + There\'s another splice in progress + Aborted due to an error + Channel is disconnected + Funding has failed [%1$s] + Not enough funds + Cannot start transaction session with the peer + Interactive tx session failed [%1$s] + Invalid splice-out pubkey script + A splice payment is already in progress + Invalid liquidity-ads request: [%1$s] + Invalid channel parameters: [%1$s] + Unexpected error: [%1$s] + + + + Delete wallet + This screens allows you delete this wallet from your device. + All data for this wallet will be deleted. This includes your payments history. + Save payments history + Review + + Confirm wallet deletion + The wallet will be completely deleted from this device. + This wallet\'s seed and its payments history will be deleted from the disk. You will be prompted to use another wallet, or create a new one. + Other wallets that you have already imported will not be deleted. + Don\'t lose your funds + I understand that if I lose the recovery phrase after deleting the wallet, any remaining funds would be permanently lost. + Delete wallet + Shutting down… + Deleting preferences… + Deleting seed… + Deleting databases… + The wallet has been successfully reset. + Reset failed + + + + Swap-in signer + This debugging tool lets you sign swap-in inputs. Only use if you understand what it does. + Unsigned tx + Server nonce + Sign + Signing… + User signature + Invalid unsigned transaction + Check that the input is complete and not missing any character. + Failed to sign input + + + + This screen allows you to link a Bolt12 code or a Lightning address to a name.\n\nThese contacts data are specific to Phoenix and stored locally. + + + + Experimental features + + Bip353 DNS address + No address yet… + Claim my address + Claiming address… + Failed to claim address + + + + Enter Lock PIN + Enter Spending PIN + Enter Spending PIN to view the seed] + Enter PIN to continue + Create Lock PIN + Create Spending PIN + Confirm PIN + + Checking PIN… + Incorrect + Locked for %1$s + + An error occurred + Malformed PIN + PIN mismatch! + Error when saving PIN + + + + APDU service for Phoenix to emulate a NFC tag + AID for the NFC tag emulated by Phoenix + + NFC is busy + NFC is not available + NFC is disabled + Tag emulation is not supported + + Nfc + Hold near the NFC reader + Ready to scan + Hold near the NFC device to read it + + + + Currency Converter + Done + Enter amount in %1$s + Add new currency… + Last refreshed: %1$s + Other… + Select a currency + No match found… + + + + Wallet + Add new wallet + Lock + + + Enter a name + Default Wallet + If a default wallet is selected, it will be automatically opened on app launch. + Hidden Wallet (WIP) + Wallet will not be visible in selector screens. To access the wallet, you must enter its lock PIN. + Pick an avatar + + + + I understand. + + + + Phoenix is running in the background + %1$s Received %2$s + + Please start wallet + An incoming settlement is pending. + + A payment is pending + Start Phoenix so the payment can be finalised in due course. + + Missed incoming payment + Phoenix was unable to start in the background. + + On-chain deposit pending (+%1$s) + Payment rejected (+%1$s) + Automated channel management is disabled. Tap for details. + Automated channel management is disabled. This deposit will expire by %1$s. + The fee was %1$s, but your max fee was set to %2$s. Tap for details. + The fee was %1$s, but your max fee was set to %2$s. This deposit will expire by %3$s. + The fee was %1$s which is more than %2$s%% of the amount received. Tap for details. + The fee was %1$s which is more than %2$s%% of the amount received. This deposit will expire by %3$s. + Payment amount is too low. + An error occurred during funding. Please try again later. + + Please start Phoenix + Some of your channels may have closed. + + + + General + Fees + Privacy & Security + Advanced + Danger zone + + Display + Wallet info + Channel management + Recovery phrase + Access control + Payment channels + Logs + Electrum server + Delete wallet + Close channels + Force-close channels + Tor + About + Payment options + Payment history + Notifications + Contacts + Currency converter + Add liquidity + + + + Confirming + Waiting for confirmation first before they can be swapped to Lightning. + + Waiting for swap + Will deploy to Lightning when mining fees are below %1$s. + Will remain on-chain because automated channels management is disabled. + Will deploy to Lightning when conditions apply. + Attention! Some funds will expire soon and won\'t be eligible for a swap anymore. + + Expired + Cannot be swapped anymore, after 4 months waiting. These funds must be spent manually. + + Final wallet + These funds come from closed Lightning channels. They must be spent manually. + On-chain balance + + Background processing restricted + Phoenix may not be able to receive payments when it is in the background, or when it is closed. + This happens because: + The device is in power saving mode + FCM notifications unavailable + If you\'re on GrapheneOS or CalyxOS, install Google Play Services to get FCM notifications. Check the FAQ for guidance. + + + + Settings + Send + Receive + + + + Create new wallet + Restore my wallet + + Phoenix is only on Lightning + Phoenix will only display funds that have already been managed by Phoenix. Funds attached to a seed generated by another application will not appear here (this includes on-chain funds). + Beware of using the same seed in parallel + Do not use the same seed simultaneously on different devices. This can cause conflicts between the two instances of Phoenix, and result in Lightning channels being closed. + + Your wallet\'s seed is a list of 12 English words. Type-in each word of this list in the box below. + Enter word #%1$s + This is not a valid word. + This seed is valid + You can now proceed and restore your wallet + This seed is not valid + Make sure you entered the correct words in the right order. + + Import payments history + Optional. Use this button to restore a payments database file from another device. + Will restore payments history + Use another file + Cannot restore payments + Try again + The file cannot be decrypted. Make sure you are using a Phoenix database file (not a CSV), and that this file matches the wallet you\'re restoring. + This file cannot be opened. Try again. + This file could not be written to the application\'s data folder. Try again. + Restore wallet + + + + Update required + This version of Phoenix (v%1$s) is not compatible with your wallet. Please update, or use a compatible version. + Update on Google Play + + + + Payment will fail + On-chain fee expected + Tap to know more + + Dismiss + Enable automated channels + Configure fee limit + + An on-chain operation will be likely required for you to receive this amount.\n\nThe fee is estimated to be around %1$s. + An on-chain operation will likely be required for you to receive this amount. + + A fee of %1$s is expected to receive this amount, and that fee is above your limit of %2$s.\n\nIncrease this limit in the channel management settings. + A fee of %1$s is expected to receive this amount, and that fee is above your limit of %2$s.\n\nIncrease this limit in the channel management settings, or request additional liquidity. + A fee of %1$s is expected to receive this amount, and that fee is more than %2$s%% of the amount.\n\nIncrease this limit in the channel management settings. + A fee of %1$s is expected to receive this amount, and that fee is more than %2$s%% of the amount.\n\nIncrease this limit in the channel management settings, or request additional liquidity. + + Inbound liquidity is insufficient for this amount, and you have disabled automated channel management. + + + + Tor is enabled + Phoenix may have issues receiving payments. Make sure the app stays open in the foreground and that connection is stable. + + + + This invoice is expired. + This payment is already being processed. Please wait for it to complete. + This payment has already been paid. + This payment does not use the same blockchain as your wallet. + Failed to process this LNURL link. Make sure it is valid. + This type of LNURL is not supported yet. + This is not a supported payment request. + Unable to retrieve data for this address. You may be experiencing a connectivity issue. + Name \"%1$s\" is not found on \"%2$s\". + This address uses an invalid Bip21 resource. + This address uses an invalid Bolt12 offer. + This address is hosted on an unsecure DNS. DNSSEC must be enabled. + + + + Must be at least %1$s + Must be no more than %1$s + + + + Bitcoin address + Send all funds to a Bitcoin address. All payments channels will be closed. + Confirm closing + All the funds will be sent to: + Miner fees estimated to: + Fee cost could not be estimated. + + Force-close channels + This screen allows you to unilaterally close your channels.\n\nThis is not a \"fix-everything\" magic button: it is here as a safety measure and should only be used in extreme scenarios. For example, if your peer (ACINQ) disappears permanently, preventing you from spending your money. In all other cases, if you experience issues with Phoenix you should contact support.\n\nForce closing channels will cost you money (to cover the on-chain fees) and will cause your funds to be locked for days.\n\nDo not uninstall the app until your channels are fully closed, or you will lose money.\n\nDo not use this feature if you don\'t fully understand what it does. + Funds will eventually be sent to the final wallet: + + Confirm force-closing + All the funds will be sent to your final wallet, after a significant delay. + Force-close all my channels + + + + Backup your wallet to prevent losing your bitcoins. + Backup my wallet + + Enable Android notifications + Notifications are disabled in Android settings. Phoenix won\'t be able to notify you when a payment is processing. + Enable + + A deposit will expire soon. + View details + + Phoenix regularly monitors the blockchain when in the background, but was unable to do so the last few days.\n\nMake sure Android does not block Phoenix, and that it can connect to Electrum. + Dismiss + + An update is available + A critical update is available. You should update Phoenix as soon as possible. + Update on Google Play + + On-chain fees are high. + See how Phoenix is affected + + Cannot access the Tor network. Phoenix will not function correctly. + Fix it + + On-chain funds pending (+%1$s) + An incoming payment has been recently rejected + %1$d incoming payments recently rejected + Payment rejected (+%1$s) + Automated channel management is disabled. + The fee was %1$s, but your max fee was set to %2$s. + The fee was %1$s which is more than %2$s%% of the amount. + Tap to configure. + View details + + Watchtower report + %1$d channel was successfully checked on %2$s. No issues were found. + %1$d channels were successfully checked on %2$s. No issues were found. + Watchtower alert + Revoked commitments were found on %1$s for channel(s): %2$s. This channel may be closed. + + + + The recovery phrase (sometimes called a seed), is a list of 12 English words. It allows you to recover full access to your funds if needed.\n\nOnly you alone possess this seed. Keep it private.\n\nDo not share this seed with anyone.\nBeware of phishing. The developers of Phoenix will never ask for your seed.\n\nDo not lose this seed.\nSave it somewhere safe (not on this phone). If you lose your seed and your phone, you\'ve lost your funds. + Display seed + KEEP THIS SEED SAFE.\nDO NOT SHARE. + You have not backed up your recovery phrase! + If you do not back it up and you lose access to Phoenix, you will lose your funds! + I have saved my recovery phrase somewhere safe. + I understand that if I lose my phone and my recovery phrase, then I will lose the funds in my wallet. + + + + Miner fees + Fees paid to the Bitcoin network miners to process the on-chain transaction. + Service fees + Fees paid for the creation of a new payment channel. This is not always required. + + Liquidity + Service fees + Fees paid for the liquidity service. + Miner fees + Fees paid to the Bitcoin network miners to process the on-chain transaction. + Caused by + This liquidity was required to receive a payment. + See how to optimise + + + + You can anonymously sign-in and authorize an action on: + You can redeem funds from\n%1$s. + The withdrawal request has been sent to %1$s.\n\nIt may take some time before they send the funds. Please keep the app online in the meanwhile. + + + + Enabling Tor + This requires installing a third-party Tor Proxy VPN app such as Orbot. + Tor can improve privacy, but may cause performance issues and missed payments. + Disabling Tor + If you disable this option, your IP address may be revealed to various service providers. + Are you sure you want to proceed ? + Processing changes… + + No access to the Tor network + Fix it + Phoenix needs access to Tor to function properly. + Make sure your Tor Proxy VPN app is up and running, and that it\'s connected to Tor. + If you don\'t have a Tor VPN app, install one. We recommend Orbot. + Open Tor settings + Open Orbot page + + + + Phoenix is a Bitcoin wallet using the Lightning network for sending and receiving payments.\n\nIt is a free open source software, developed by ACINQ under the Apache 2.0 License. + Safeguarding your key + This wallet is self-custodial: you have sole custody of the wallet\'s 12-words seed key.\n\nThis key gives access to your money. Do not reveal it to anyone, and beware of phishing. + Exchange rates + Bitcoin/fiat exchange rates are retrieved from various third-party APIs:\n\n- Blockchain.info\n- Coinbase.com\n- Bluelytics.com.ar\n- Yadio.io\n\nThose rates may not be accurate. Always check the actual Bitcoin amount before sending a payment. + + + + Welcome! + With Phoenix, sending and receiving bitcoins is easy and safe. + Next + + Bitcoin supercharged + Phoenix uses payment channels to make Bitcoin fast and private. + Next + + Your key, your bitcoins + Phoenix is self-custodial. You take control. + You can restore your wallet at anytime using your secret key. Keep it safe! + Get started + + + + Swap-in wallet + The swap-in wallet manages on-chain funds deposited to Phoenix.\n\nIt swaps them automatically to Lightning when possible, according to your channels management setting. + See how it works + Tap to configure + There are no swaps in progress. + + On-chain funds are automatically swapped into Lightning if the fee paid to miners is less than %1$s (can be configured). + Funds not swapped after %1$d months are recoverable on-chain. + Automated channels management is disabled. No swap will occur, funds will remain on-chain. + + A swap attempt failed %1$s + Channels management was disabled. + This swap will expire in a day! + This swap will expire in %1$s days. + + Timed out + These funds will be available from %1$s days onwards. + + Cancelled funds + These funds were not swapped in time. Tap to spend. + + The final wallet is where funds are sent when your Lightning channels are closed or when there is a problem. It usually should be empty. + + + + Incoming payments sometimes require on-chain transactions. This does not always happen, only when needed. + Fees are currently estimated at around %1$s (≈%2$s). + Automated channel management + Incoming payments that require on-chain operations will be rejected. + + Max fee amount + Payments whose fees exceed that value will be rejected. + This value is too low. + Below the expected fee. Some payments may be rejected. + + Advanced options + Channel management is disabled. It can be enabled in the Channel management setting screen. + Attention! + This screen is for advanced users. Do not change these settings unless you understand their purposes. + + Additional verifications + Percentage check + Check the fee relative to the amount received. This option is useful as a sanity check for small payments. + Policy overrides + Skip absolute fee check for Lightning + When enabled, incoming Lightning payments will ignore the absolute max fee limit. Only the percentage check will apply.\n\nAttention: if the Bitcoin mempool feerate is high, incoming LN payments requiring an on-chain operation could be expensive. + Save policy + Request inbound liquidity + + + Phoenix allows you to receive payments on Bitcoin\'s blockchain (L1) and Bitcoin\'s Lightning layer (L2). + \n\n + - the blockchain layer (L1) is slower, and generally much more expensive (requires miner fees) + \n + - the Lightning layer (L2) is much faster, and generally much cheaper (especially for smaller payments) + \n\n + When you receive a payment on L1, Phoenix will automatically move the funds to L2 IF the miner fees adhere to your configured fee policy. + \n\n + Payments you receive on L2 can be received instantly and for zero fees. However, occasionally an L1 operation is also required in order to manage the L2 payment channel. This can be done automatically IF the miner fees adhere to your configured fee policy. + + + + + No channels yet! + You first need funds in the wallet to use this feature. + Plan ahead your liquidity + Inbound liquidity lets you avoid on-chain transaction fees for future payments received over Lightning.\n\nBy requesting more liquidity now, you can save fees later. + More info + Current liquidity + + Request liquidity + Estimate liquidity cost + Estimating cost… + Miner fee + Mining fee contribution for the underlying on-chain transaction. + Service fee + This fee goes to the service providing the liquidity. + Duration + 1 year + As you receive funds, liquidity will be consumed and become your balance. After one year, the remaining unused liquidity will be reclaimed by the service. + The total fee is more than 25% of the liquidity amount requested. + The total fees exceed your balance. + + Review + You are requesting an initial amount of liquidity. Liquidity is not constant over time: as you receive funds over Lightning, the liquidity will be consumed and become your balance. + After one year, the remaining unused liquidity will be reclaimed by the service. + + Accept + Processing splice… + + Liquidity successfully added! + Amount added: %1$s + + Liquidity request has failed + Channels are not available. Try again later. + The requested amount is invalid. + + + + Unconfirmed - tap to accelerate + + + + Accelerate my transactions. + You can make all your unconfirmed transactions use a higher feerate to encourage miners to favour your payments. + This feerate is less than what your unconfirmed transactions are already using. Use a higher feerate. + + + + Channels are closing. + Channels are already processing a splice. Try again later. + Fee is insufficient. + This payment exceeds your balance. + The payment amount is too big - try splitting it in several parts. + The payment amount is too small. + The expiry of this payment is too far in the future. + The payment was rejected by the recipient. This particular invoice may have already been paid. + The recipient is offline. + The payment could not be relayed to the recipient (probably insufficient inbound liquidity). + An error occurred on a node in the payment route. The payment may succeed if you try again. + You have too many pending payments. Try again once they are settled. + + The ID of the payment is not valid. Try again. + This invoice has already been paid. + Your channel is not connected yet. Wait for a stable connection and try again. + Your channel is still in the process of being opened. Wait and try again. + This invoice uses unsupported features. Make sure you\'re on the latest Phoenix version. + The payment amount is invalid. + The payment could not be sent through your existing channels. + Recipient is not reachable, or does not have enough inbound liquidity. + An unknown error occurred and payment has failed. + The wallet was restarted while the payment was processing. + + + + Low feerate! + Transactions with insufficient feerate may linger for days or weeks without confirming. + Choosing the feerate is your responsibility. Once sent, this transaction cannot be cancelled, only accelerated with higher fees. + Are you sure you want to proceed? + + + + Channel size impacted + Funds sent on-chain are taken from your side of the channel, reducing the channel size by the same amount. Your inbound liquidity remains unchanged. + Don\'t show this message again. + + + + Spend cancelled swap-ins + No cancelled swap-ins yet. + Available: %1$s (%2$s) + Use this screen to spend on-chain deposits that were not swapped in time. This does not affect your Lightning channels. + Make sure the destination address is valid, and use a reasonable feerate. + Estimate fees + Estimating fees… + Broadcast + Broadcasting… + Transaction error. + This address is not valid. + Cannot create the refund transaction. + Transaction published. + You can find the transaction below. It will not appear in your payments history, so make a copy of its ID now if needed. + + + + Spend funds from final wallet + No funds available + Amount available + Use this screen to spend funds from your final wallet. These funds come from channels that have been closed in the past. This does not affect your existing Lightning channels. + + + + This is a human-readable address for your Bolt12 payment request. + Want a prettier address? Use third-party services, or self-host the address! + \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/AppVersion.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/AppVersion.kt new file mode 100644 index 00000000..a29b6768 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/AppVersion.kt @@ -0,0 +1,6 @@ +package ac.cord.auxiliary.compose + +expect object AppVersion { + val versionName: String + val versionCode: String // iOS uses string for build number +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/buttons/Clickable.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/buttons/Clickable.kt new file mode 100644 index 00000000..ecbedfcc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/buttons/Clickable.kt @@ -0,0 +1,83 @@ +/* + * 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 com.machankura.compose.ui.composable.widgets.buttons + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Indication +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp + +@Composable +fun Clickable( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onLongClick: (() -> Unit)? = null, + textStyle: TextStyle = MaterialTheme.typography.labelMedium, + border: BorderStroke? = null, + backgroundColor: Color = Color.Unspecified, // transparent by default! + shape: Shape = RectangleShape, + clickDescription: String = "", + internalPadding: PaddingValues = PaddingValues(0.dp), + indication: Indication? = LocalIndication.current, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable () -> Unit, +) { + val contentColor = LocalContentColor.current + Surface( + shape = shape, + color = backgroundColor, + border = border, + contentColor = contentColor, + modifier = modifier + .clip(shape) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + onLongClickLabel = null, + onDoubleClick = null, + enabled = enabled, + role = Role.Button, + onClickLabel = clickDescription, + interactionSource = interactionSource, + indication = indication + ) + .padding(internalPadding) + ) { + content() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/dialogs/BottomSheetDialog.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/dialogs/BottomSheetDialog.kt new file mode 100644 index 00000000..3470c570 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/dialogs/BottomSheetDialog.kt @@ -0,0 +1,88 @@ +/* + * 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 com.machankura.compose.ui.composable.widgets.dialogs + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetProperties +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** Provides a Material3 [ModalBottomSheet] with some presets. Content is contained in a [Column] with [internalPadding]. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ModalBottomSheet( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + skipPartiallyExpanded: Boolean = false, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + containerColor: Color = MaterialTheme.colorScheme.surface, + contentColor: Color = MaterialTheme.colorScheme.onSurface, + scrimAlpha: Float = 0.2f, + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults. DragHandle() }, + contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets }, + contentHeight: Dp = Dp.Unspecified, + internalPadding: PaddingValues = PaddingValues(top = 0.dp, start = 20.dp, end = 20.dp, bottom = 64.dp), + isContentScrollable: Boolean = true, + dismissOnScrimClick: Boolean = true, + dismissOnBack: Boolean = true, + content: @Composable ColumnScope.() -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded, confirmValueChange = { dismissOnScrimClick }) + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = { + // executed when user click outside the sheet, and after sheet has been hidden thru state. + onDismiss() + }, + sheetMaxWidth = 500.dp, + modifier = modifier, + containerColor = containerColor, + contentColor = contentColor, + dragHandle = dragHandle, + contentWindowInsets = contentWindowInsets, + scrimColor = MaterialTheme.colorScheme.onBackground.copy(alpha = scrimAlpha), + properties = ModalBottomSheetProperties(shouldDismissOnBackPress = dismissOnBack) + ) { + Column( + horizontalAlignment = horizontalAlignment, + modifier = Modifier + .fillMaxWidth() + .height(contentHeight) + .then(if (isContentScrollable) Modifier.verticalScroll(rememberScrollState()) else Modifier) + .padding(internalPadding) + ) { + content() + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/wallet/WalletAvatar.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/wallet/WalletAvatar.kt new file mode 100644 index 00000000..47ff23a1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/wallet/WalletAvatar.kt @@ -0,0 +1,112 @@ +/* + * 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 ac.cord.auxiliary.compose.ui.composable.widgets.wallet + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.machankura.compose.ui.composable.widgets.buttons.Clickable +import com.machankura.compose.ui.composable.widgets.dialogs.ModalBottomSheet + +object WalletAvatars { + val list = listOf( + "😃", "🙃", "🤑", "😎", "🥳", "🧐", "🤠", "🤖", + "👑", "🚀", "✈️", "🚣", "⛵", "🚗", "🏍️", + "🧘", "⛹️", "🤾", "🚴", "🧗", "🏋️", "🤼", "🏌️", "🏇", "🤺", "⛷️", "🏂", "🏄", "🏊", "🥷", "💂", "🤵", "🤵‍♀️", "🧑‍🚀", "👷", "👮", "🧑‍🔬", "🧑‍🔧", "🧑‍🚒", "🧑‍🌾", "🧑‍🎓", "🧑‍⚖️", "👶", "🧒", "🧑", "🧓", "👧", "👩", "👵", + "🐶", "🐱", "🐴", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🐔", "🐧", "🐤", "🦄", "🐝", "🐙", "🐬", "🐋", "🦜", "🐟", "🐛", + "🍏", "🍎", "🍌", "🍇", "🍓", "🍺", "🍿", "🥖", "🧀", "🍔", + "🌹", "🌻", "☘️", "❄️", "⛰️", "🌴", "🌳", "🌲", "⚡", "🌧️", "🌩️", "🌦️", "☀️", + "🎂", "🎁", "🎈", "🎉", "🎃", "🎄", "🎀", + "🔥", "💫", "⭐", "✨", "💰", "💸", "📈", "🎯", "♥️" + ) +} + +@Composable +fun WalletAvatar(avatar: String, fontSize: TextUnit = 28.sp, borderColor: Color = Color.Transparent, backgroundColor: Color = MaterialTheme.colorScheme.surface, internalPadding: PaddingValues = PaddingValues(10.dp)) { + Box(modifier = Modifier.clip(CircleShape).background(backgroundColor).border(1.dp, color = borderColor, shape = CircleShape).padding(internalPadding), contentAlignment = Alignment.Center) { + Text( + text = avatar, + fontSize = fontSize, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +fun ColumnScope.AvatarPicker( + avatar: String, + onAvatarChange: (String) -> Unit, +) { + var showPickerDialog by remember { mutableStateOf(false) } + + Clickable(onClick = { showPickerDialog = true }, modifier = Modifier.align(Alignment.CenterHorizontally)) { + WalletAvatar(avatar, fontSize = 48.sp, internalPadding = PaddingValues(16.dp)) + } + + if (showPickerDialog) { + ModalBottomSheet( + onDismiss = { showPickerDialog = false }, + horizontalAlignment = Alignment.CenterHorizontally, + internalPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp), + isContentScrollable = false, + ) { +// val s = stringResource(Res.string.wallet_edit_pick_avatar) + val s = "Edit avatar" + Text(s, style = MaterialTheme.typography.bodyMedium) + Spacer(modifier = Modifier.height(24.dp)) + LazyVerticalGrid( + columns = GridCells.Fixed(6), + modifier = Modifier.fillMaxWidth() + ) { + items(WalletAvatars.list) { emoji -> + Clickable(onClick = { + showPickerDialog = false + onAvatarChange(emoji) + }) { + val mutedBgColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + WalletAvatar(emoji, backgroundColor = if (emoji == avatar) mutedBgColor else Color.Transparent) + } + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/conf/Lsp.kt b/composeApp/src/commonMain/kotlin/fr/acinq/conf/Lsp.kt new file mode 100644 index 00000000..fbcca94c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/conf/Lsp.kt @@ -0,0 +1,56 @@ +package fr.acinq.phoenixd.conf + +import fr.acinq.bitcoin.Chain +import fr.acinq.bitcoin.PublicKey +import fr.acinq.lightning.* +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") + } + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/conf/Seed.kt b/composeApp/src/commonMain/kotlin/fr/acinq/conf/Seed.kt new file mode 100644 index 00000000..b1bbe430 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/conf/Seed.kt @@ -0,0 +1,36 @@ +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) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/conf/SeedSpec.kt b/composeApp/src/commonMain/kotlin/fr/acinq/conf/SeedSpec.kt new file mode 100644 index 00000000..b8fd60d1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/conf/SeedSpec.kt @@ -0,0 +1,9 @@ +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() +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/Ambients.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/Ambients.kt new file mode 100644 index 00000000..dd293c30 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/Ambients.kt @@ -0,0 +1,55 @@ +/* + * 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.* +import fr.acinq.phoenix.utils.preferences.GlobalPrefs +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 { null } +val LocalBusiness = compositionLocalOf { null } +val LocalUserPrefs = staticCompositionLocalOf { null } +val LocalInternalPrefs = staticCompositionLocalOf { null } +val LocalControllerFactory = staticCompositionLocalOf { null } +val LocalNavController = staticCompositionLocalOf { null } +val LocalBitcoinUnits = compositionLocalOf { PreferredBitcoinUnits(primary = BitcoinUnit.Sat) } +val LocalFiatCurrencies = compositionLocalOf { PreferredFiatCurrencies(primary = FiatCurrency.USD, others = emptyList()) } +val LocalExchangeRatesMap = compositionLocalOf> { 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] } diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/PhoenixBusiness.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/PhoenixBusiness.kt new file mode 100644 index 00000000..5eecbf1d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/PhoenixBusiness.kt @@ -0,0 +1,163 @@ +/* + * 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.* +import fr.acinq.phoenix.controllers.config.* +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.* +import fr.acinq.phoenix.managers.global.CurrencyManager +import fr.acinq.phoenix.managers.global.FeerateManager +import fr.acinq.phoenix.utils.* +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) + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/PhoenixGlobal.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/PhoenixGlobal.kt new file mode 100644 index 00000000..3aa65adb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/PhoenixGlobal.kt @@ -0,0 +1,61 @@ +/* + * 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() + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/AppController.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/AppController.kt new file mode 100644 index 00000000..b507576e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/AppController.kt @@ -0,0 +1,75 @@ +package fr.acinq.phoenix.controllers + +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.logging.debug +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.flow.MutableStateFlow + + +abstract class AppController( + loggerFactory: LoggerFactory, + firstModel: M +) : MVI.Controller(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>() + + 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() + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/ControllerFactory.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/ControllerFactory.kt new file mode 100644 index 00000000..e20fad09 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/ControllerFactory.kt @@ -0,0 +1,31 @@ +package fr.acinq.phoenix.controllers + +import fr.acinq.phoenix.controllers.config.* +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 +typealias HomeController = MVI.Controller +typealias InitializationController = MVI.Controller +typealias ReceiveController = MVI.Controller +typealias RestoreWalletController = MVI.Controller + +typealias CloseChannelsConfigurationController = MVI.Controller +typealias ConfigurationController = MVI.Controller +typealias ElectrumConfigurationController = MVI.Controller + +/** 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 +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/MVI.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/MVI.kt new file mode 100644 index 00000000..23ccd4a5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/MVI.kt @@ -0,0 +1,32 @@ +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(val firstModel: M) { + + abstract fun subscribe(onModel: (M) -> Unit): () -> Unit + + abstract fun intent(intent: I) + + abstract fun stop() + + open class Mock(val model: M) : Controller(model) { + override fun subscribe(onModel: (M) -> Unit): () -> Unit { + onModel(model) + return ({}) + } + override fun intent(intent: I) {} + override fun stop() {} + } + + } + +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/CloseChannelsConfiguration.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/CloseChannelsConfiguration.kt new file mode 100644 index 00000000..4325df27 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/CloseChannelsConfiguration.kt @@ -0,0 +1,42 @@ +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, + val address: String // this wallet's bitcoin address + ) : Model() + data class ChannelsClosed( + val channels: List, + val closing: Set // 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() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/CloseChannelsConfigurationController.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/CloseChannelsConfigurationController.kt new file mode 100644 index 00000000..f4c87e25 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/CloseChannelsConfigurationController.kt @@ -0,0 +1,170 @@ +package fr.acinq.phoenix.controllers.config + +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.Chain +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.lightning.blockchain.fee.FeeratePerByte +import fr.acinq.lightning.blockchain.fee.FeeratePerKw +import fr.acinq.lightning.channel.* +import fr.acinq.lightning.channel.states.* +import fr.acinq.lightning.io.WrappedChannelCommand +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.managers.PeerManager +import fr.acinq.phoenix.controllers.AppController +import fr.acinq.phoenix.controllers.config.CloseChannelsConfiguration.Model.ChannelInfoStatus +import fr.acinq.phoenix.utils.Parser +import fr.acinq.phoenix.utils.extensions.localBalance +import fr.acinq.lightning.logging.info +import fr.acinq.phoenix.data.BitcoinUri +import fr.acinq.phoenix.managers.AppConfigurationManager +import fr.acinq.phoenix.managers.phoenixFinalWallet +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +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( + 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? = 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) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/Configuration.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/Configuration.kt new file mode 100644 index 00000000..244424d5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/Configuration.kt @@ -0,0 +1,13 @@ +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() +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/ConfigurationController.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/ConfigurationController.kt new file mode 100644 index 00000000..1f0a5d35 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/ConfigurationController.kt @@ -0,0 +1,35 @@ +package fr.acinq.phoenix.controllers.config + +import co.touchlab.kermit.Logger +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.managers.WalletManager +import fr.acinq.phoenix.controllers.AppController +import kotlinx.coroutines.launch + + +class AppConfigurationController( + loggerFactory: LoggerFactory, + private val walletManager: WalletManager +) : AppController( + 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") +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/ElectrumConfiguration.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/ElectrumConfiguration.kt new file mode 100644 index 00000000..eabb3e6c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/ElectrumConfiguration.kt @@ -0,0 +1,26 @@ +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() + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/ElectrumConfigurationController.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/ElectrumConfigurationController.kt new file mode 100644 index 00000000..c54ca1b0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/ElectrumConfigurationController.kt @@ -0,0 +1,62 @@ +package fr.acinq.phoenix.controllers.config + +import co.touchlab.kermit.Logger +import fr.acinq.lightning.blockchain.electrum.ElectrumClient +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.managers.AppConfigurationManager +import fr.acinq.phoenix.managers.AppConnectionsDaemon +import fr.acinq.phoenix.controllers.AppController +import fr.acinq.lightning.logging.LoggerFactory +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch + + +class AppElectrumConfigurationController( + loggerFactory: LoggerFactory, + private val configurationManager: AppConfigurationManager, + private val electrumClient: ElectrumClient, + private val appConnectionsDaemon: AppConnectionsDaemon? +) : AppController( + 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) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/InitController.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/InitController.kt new file mode 100644 index 00000000..090009ba --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/InitController.kt @@ -0,0 +1,34 @@ +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( + 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)) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/Initialization.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/Initialization.kt new file mode 100644 index 00000000..01df392b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/Initialization.kt @@ -0,0 +1,29 @@ +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, + 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" + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/RestoreWallet.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/RestoreWallet.kt new file mode 100644 index 00000000..dc5c096c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/RestoreWallet.kt @@ -0,0 +1,79 @@ +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 + ) : Model() { + override fun toString(): String = "FilteredWordlist" + } + + object InvalidMnemonics : Model() + data class ValidMnemonics( + val mnemonics: List, + 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 + if (!seed.contentEquals(other.seed)) return false + return true + } + + 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, + val language: MnemonicLanguage + ) : Intent() { + override fun toString() = "Validate" + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/RestoreWalletController.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/RestoreWalletController.kt new file mode 100644 index 00000000..0407c547 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/RestoreWalletController.kt @@ -0,0 +1,65 @@ +package fr.acinq.phoenix.controllers.init + +import co.touchlab.kermit.Logger +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( + 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) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/Content.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/Content.kt new file mode 100644 index 00000000..c52bdc74 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/Content.kt @@ -0,0 +1,15 @@ +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() + +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/ContentController.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/ContentController.kt new file mode 100644 index 00000000..7917a398 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/ContentController.kt @@ -0,0 +1,39 @@ +package fr.acinq.phoenix.controllers.main + +import co.touchlab.kermit.Logger +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( + 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") +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/Home.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/Home.kt new file mode 100644 index 00000000..ef511422 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/Home.kt @@ -0,0 +1,17 @@ +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() +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/HomeController.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/HomeController.kt new file mode 100644 index 00000000..73677077 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/HomeController.kt @@ -0,0 +1,31 @@ +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( + 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) {} +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/payments/Receive.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/payments/Receive.kt new file mode 100644 index 00000000..c4923aab --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/payments/Receive.kt @@ -0,0 +1,22 @@ +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() + } + +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/payments/ReceiveController.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/payments/ReceiveController.kt new file mode 100644 index 00000000..22fe1552 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/payments/ReceiveController.kt @@ -0,0 +1,60 @@ +/* + * 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( + 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)) + } + } + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/csv/CsvWriter.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/csv/CsvWriter.kt new file mode 100644 index 00000000..3072d570 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/csv/CsvWriter.kt @@ -0,0 +1,86 @@ +/* + * 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) { + 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 + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/csv/WalletPaymentCsvWriter.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/csv/WalletPaymentCsvWriter.kt new file mode 100644 index 00000000..f5f947fa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/csv/WalletPaymentCsvWriter.kt @@ -0,0 +1,278 @@ +/* + * 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.* +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.Instant +import kotlin.time.ExperimentalTime + +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().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().map { it.miningFee }.sum(), + serviceFee = payment.parts.filterIsInstance().map { it.serviceFee }.sum(), + paymentHash = payment.paymentHash, + txId = payment.parts.filterIsInstance().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) } + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/AppConfiguration.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/AppConfiguration.kt new file mode 100644 index 00000000..88fb5be8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/AppConfiguration.kt @@ -0,0 +1,291 @@ +package fr.acinq.phoenix.data + +import aux.composeapp.generated.resources.Res +import aux.composeapp.generated.resources.* +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 + + +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 { + 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 == FiatCurrency.ARS -> getString(Res.string.currency_ars_official) + this == FiatCurrency.ARS_BM -> getString(Res.string.currency_ars_bm) + this == FiatCurrency.CUP -> getString(Res.string.currency_cup_official) + this == FiatCurrency.CUP_FM -> getString(Res.string.currency_cup_fm) + this == FiatCurrency.LBP -> getString(Res.string.currency_lbp_official) + this == FiatCurrency.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 +) { + constructor(primary: FiatCurrency, others: List) : + this(primary = primary, others = others.toSet()) + + val all: Set + get() { + return if (others.contains(primary)) { + others + } else { + others.toMutableSet().apply { add(primary) } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/BitcoinAddress.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/BitcoinAddress.kt new file mode 100644 index 00000000..28cabbb8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/BitcoinAddress.kt @@ -0,0 +1,64 @@ +/* + * 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.* + +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>): BitcoinUriError() + object InvalidUri: BitcoinUriError() +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ChannelsWatcherOutcome.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ChannelsWatcherOutcome.kt new file mode 100644 index 00000000..3572b607 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ChannelsWatcherOutcome.kt @@ -0,0 +1,17 @@ +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() +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ContactInfo.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ContactInfo.kt new file mode 100644 index 00000000..e8d989cb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ContactInfo.kt @@ -0,0 +1,115 @@ +/* + * 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, + val addresses: List, + val publicKeys: List, +) { + constructor( + id: UUID, + name: String, + photoUri: String?, + useOfferKey: Boolean, + offers: List, + addresses: List + ) : 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 by lazy { (offers + addresses).sortedByDescending { it.createdAt } } + +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/DecryptSeedResult.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/DecryptSeedResult.kt new file mode 100644 index 00000000..44f70c85 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/DecryptSeedResult.kt @@ -0,0 +1,13 @@ +package fr.acinq.phoenix.data + +sealed class DecryptSeedResult { + data class Success(val userWalletsMap: Map): 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() + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/DefaultOffer.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/DefaultOffer.kt new file mode 100644 index 00000000..7b09af6e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/DefaultOffer.kt @@ -0,0 +1,32 @@ +/* + * 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 +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ElectrumServers.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ElectrumServers.kt new file mode 100644 index 00000000..9b391af0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ElectrumServers.kt @@ -0,0 +1,184 @@ +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 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 diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ExchangeRates.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ExchangeRates.kt new file mode 100644 index 00000000..ce6dfc73 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ExchangeRates.kt @@ -0,0 +1,177 @@ +package fr.acinq.phoenix.data + +import fr.acinq.phoenix.controllers.MVI +import fr.acinq.phoenix.controllers.main.Home +import kotlinx.datetime.Instant +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 + +/** + * 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 + ) +} + +/** + * 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 +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/LocalChannelInfo.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/LocalChannelInfo.kt new file mode 100644 index 00000000..e1c627f9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/LocalChannelInfo.kt @@ -0,0 +1,188 @@ +/* + * 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.* +import fr.acinq.lightning.json.JsonSerializers +import fr.acinq.lightning.utils.msat +import fr.acinq.phoenix.managers.PeerManager +import fr.acinq.phoenix.utils.extensions.* +import kotlinx.serialization.encodeToString + +/** + * 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 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 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?.availableForReceive(): MilliSatoshi? { + return this?.values?.availableForReceive() +} + +fun Collection.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?.canRequestLiquidity(): Boolean { + return this?.values?.canRequestLiquidity() ?: false +} + +fun Collection.canRequestLiquidity(): Boolean { + return this.any { it.isUsable } +} + +fun Map?.inFlightPaymentsCount(): Int { + return this?.values?.inFlightPaymentsCount() ?: 0 +} + +fun Collection.inFlightPaymentsCount(): Int { + return this.sumOf { it.inFlightPaymentsCount } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/MempoolFeerate.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/MempoolFeerate.kt new file mode 100644 index 00000000..c7cc977f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/MempoolFeerate.kt @@ -0,0 +1,54 @@ +/* + * 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 + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/Notification.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/Notification.kt new file mode 100644 index 00000000..53b9ce9e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/Notification.kt @@ -0,0 +1,93 @@ +/* + * 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): WatchTowerOutcome() { + constructor(channels: Set) : this(UUID.randomUUID(), currentTimestampMillis(), null, channels) + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/StartBusinessResult.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/StartBusinessResult.kt new file mode 100644 index 00000000..958eb816 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/StartBusinessResult.kt @@ -0,0 +1,12 @@ +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() + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/UserTheme.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/UserTheme.kt new file mode 100644 index 00000000..7a82050e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/UserTheme.kt @@ -0,0 +1,14 @@ +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 + } + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/Wallet.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/Wallet.kt new file mode 100644 index 00000000..302bf0b1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/Wallet.kt @@ -0,0 +1,55 @@ +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, +) { + override fun toString(): String = "UserWallet[ wallet_id=$walletId, words=*** ]" +} + +data class ActiveWallet( + val id: WalletId, + val business: PhoenixBusiness?, + val userPrefs: UserPrefs, + val internalPrefs: InternalPrefs, +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/WalletContext.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/WalletContext.kt new file mode 100644 index 00000000..2156fc0a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/WalletContext.kt @@ -0,0 +1,8 @@ +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, +) diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/WalletNotice.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/WalletNotice.kt new file mode 100644 index 00000000..2372d655 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/WalletNotice.kt @@ -0,0 +1,3 @@ +package fr.acinq.phoenix.data + +data class WalletNotice(val message: String, val index: Int) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/WalletPayment.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/WalletPayment.kt new file mode 100644 index 00000000..738c3ca4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/WalletPayment.kt @@ -0,0 +1,146 @@ +package fr.acinq.phoenix.data + +import androidx.compose.runtime.Composable +import aux.composeapp.generated.resources.Res +import aux.composeapp.generated.resources.* +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 + +/** + * 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 */ } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/Lnurl.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/Lnurl.kt new file mode 100644 index 00000000..5534bf52 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/Lnurl.kt @@ -0,0 +1,252 @@ +/* + * 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.utils.msat +import fr.acinq.phoenix.utils.Parser +import fr.acinq.lightning.logging.debug +import fr.acinq.lightning.logging.error +import fr.acinq.lightning.logging.info +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.utils.io.charsets.* +import kotlinx.serialization.json.* + +/** + * 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) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlAuth.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlAuth.kt new file mode 100644 index 00000000..1ec287b5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlAuth.kt @@ -0,0 +1,172 @@ +/* + * 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.* +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 fr.acinq.lightning.logging.debug +import io.ktor.http.* + +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 { + 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) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlError.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlError.kt new file mode 100644 index 00000000..01cf748b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlError.kt @@ -0,0 +1,68 @@ +/* + * 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.* + +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) { + object MissingK1 : Auth("missing k1 parameter") + } + + sealed class Withdraw(override val message: String?) : LnurlError(message) { + object MissingK1 : Withdraw("missing k1 parameter in auth metadata") + } + + sealed class Pay : LnurlError() { + sealed class Intent(override val message: String?) : LnurlError(message) { + object InvalidMin : Intent("invalid minimum amount") + object MissingMax : Intent("missing maximum amount parameter") + object 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") + object NoTag : LnurlError("no tag field found") + data class UnhandledTag(val tag: String) : LnurlError("unhandled tag=$tag") + object UnsafeResource : LnurlError("resource should be https") + object 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") + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlPay.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlPay.kt new file mode 100644 index 00000000..23c097e4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlPay.kt @@ -0,0 +1,221 @@ +/* + * 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.* +import kotlinx.serialization.json.* + +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): LnurlPay.Intent.Metadata = try { + val array = format.decodeFromString(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() + 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}" } + } + } + LnurlPay.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) + } + } +} + + diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlWithdraw.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlWithdraw.kt new file mode 100644 index 00000000..edca6f2f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlWithdraw.kt @@ -0,0 +1,33 @@ +/* + * 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.* + +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) + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbFactory.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbFactory.kt new file mode 100644 index 00000000..6ddaa136 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbFactory.kt @@ -0,0 +1,26 @@ +/* + * 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 diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbHooks.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbHooks.kt new file mode 100644 index 00000000..09a4eb68 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbHooks.kt @@ -0,0 +1,53 @@ +package fr.acinq.phoenix.db + +import fr.acinq.lightning.utils.UUID +import fr.acinq.phoenix.db.payments.CloudKitInterface +import fr.acinq.phoenix.db.sqldelight.AppDatabase +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? diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbInitHelper.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbInitHelper.kt new file mode 100644 index 00000000..231c985f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbInitHelper.kt @@ -0,0 +1,135 @@ +/* + * 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.* +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 { + override fun decode(databaseValue: ByteArray): UUID = UUID.fromBytes(databaseValue) + + override fun encode(value: UUID): ByteArray = value.toByteArray() +} + +object ByteVector32Adapter : ColumnAdapter { + override fun decode(databaseValue: ByteArray) = ByteVector32(databaseValue) + + override fun encode(value: ByteVector32): ByteArray = value.toByteArray() +} + +object TxIdAdapter : ColumnAdapter { + override fun decode(databaseValue: ByteArray) = TxId(databaseValue) + + override fun encode(value: TxId): ByteArray = value.value.toByteArray() +} + +object PersistedChannelStateAdapter : ColumnAdapter { + 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 { + @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 { + 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 { + 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 { + 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) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/SqliteAppDb.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/SqliteAppDb.kt new file mode 100644 index 00000000..3eccd9eb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/SqliteAppDb.kt @@ -0,0 +1,176 @@ +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 kotlin.collections.List +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) { + 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> { + // 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? { + return keyValueStoreQueries.get(key).executeAsOneOrNull()?.let { + Pair(it.value_, it.updated_at) + } + } + + suspend fun getValue(key: String, transform: (ByteArray) -> T): Pair? { + 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) = withContext(Dispatchers.Default) { + notificationsQueries.markAsRead(ids) + } + + suspend fun dismissAllNotifications() = withContext(Dispatchers.Default) { + notificationsQueries.markAllAsRead() + } + + fun listUnreadNotification(nodeIdHash: String): Flow, Notification>>> { + return notificationsQueries.listUnread(nodeIdHash) + } + + suspend fun initializeNodeIdHashColumn(nodeIdHash: String) = withContext(Dispatchers.Default) { + notificationsQueries.initializeNodeIdHashColumn(nodeIdHash) + } + + fun close() { + driver.close() + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/SqliteChannelsDb.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/SqliteChannelsDb.kt new file mode 100644 index 00000000..1b6430e6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/SqliteChannelsDb.kt @@ -0,0 +1,86 @@ +/* + * 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 kotlin.collections.List +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? { + 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 = 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> { + 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() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/SqlitePaymentsDb.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/SqlitePaymentsDb.kt new file mode 100644 index 00000000..ba49d437 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/SqlitePaymentsDb.kt @@ -0,0 +1,324 @@ +/* + * 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.* +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.logging.error +import fr.acinq.lightning.utils.* +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.* +import fr.acinq.phoenix.db.payments.PaymentsMetadataQueries +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 kotlin.collections.List +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().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().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().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().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? = withContext(Dispatchers.Default) { + _getPayment(id) + } + + fun _getPayment(id: UUID): Pair? = 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> { + return database.onChainTransactionsQueries.listUnconfirmed() + .asFlow() + .mapToList(Dispatchers.Default) + } + + suspend fun listPaymentsForTxId(txId: TxId): List = withContext(Dispatchers.Default) { + database.paymentsIncomingQueries.listByTxId(txId).executeAsList() + database.paymentsOutgoingQueries.listByTxId(txId).executeAsList() + } + + fun listPaymentsAsFlow(count: Long, skip: Long): Flow> { + 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> { + 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> { + 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 { + 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, indexes: SqliteContactsDb.ContactIndexes): List = 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().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() + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/CloudHelper.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/CloudHelper.kt new file mode 100644 index 00000000..822cd52e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/CloudHelper.kt @@ -0,0 +1,17 @@ +package fr.acinq.phoenix.db.cloud + +import io.ktor.util.* + +// 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 +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/CloudSerializers.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/CloudSerializers.kt new file mode 100644 index 00000000..1b7948da --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/CloudSerializers.kt @@ -0,0 +1,148 @@ +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 { + 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 { + 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 { + 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 { + 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") + } + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/contacts/CloudContact.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/contacts/CloudContact.kt new file mode 100644 index 00000000..edc54ae2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/contacts/CloudContact.kt @@ -0,0 +1,109 @@ +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 = 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(blob) + return when (header.version) { + Version.V0.value -> { + serializer.decodeFromByteArray(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.. { + throw IllegalArgumentException("unknown version: $version") + } + }.copy(photoUri = photoUri) + }.recoverCatching { + cborDeserializeAndUnwrap(blob, photoUri) + }.getOrNull() + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/ChannelCloseType.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/ChannelCloseType.kt new file mode 100644 index 00000000..6816b406 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/ChannelCloseType.kt @@ -0,0 +1,47 @@ +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 +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/CloudAsset.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/CloudAsset.kt new file mode 100644 index 00000000..5e960e2b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/CloudAsset.kt @@ -0,0 +1,135 @@ +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.* +import kotlinx.serialization.cbor.ByteString +import kotlinx.serialization.cbor.Cbor + +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(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) +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/CloudData.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/CloudData.kt new file mode 100644 index 00000000..4be86021 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/CloudData.kt @@ -0,0 +1,158 @@ +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.encodeToString +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): CloudData.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.. { + throw IllegalArgumentException("unknown version: $version") + } + } + }.recoverCatching { + cborSerializer().decodeFromByteArray(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() +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/InboundLiquidityPaymentWrapper.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/InboundLiquidityPaymentWrapper.kt new file mode 100644 index 00000000..bc33ba60 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/InboundLiquidityPaymentWrapper.kt @@ -0,0 +1,146 @@ +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 + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/IncomingPaymentWrapperV10Legacy.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/IncomingPaymentWrapperV10Legacy.kt new file mode 100644 index 00000000..d31d101d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/IncomingPaymentWrapperV10Legacy.kt @@ -0,0 +1,46 @@ +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 + ) +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/LightningOutgoingPartType.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/LightningOutgoingPartType.kt new file mode 100644 index 00000000..308b372f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/LightningOutgoingPartType.kt @@ -0,0 +1,68 @@ +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 + ) + } + } // + +} // diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/LightningOutgoingType.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/LightningOutgoingType.kt new file mode 100644 index 00000000..3268d386 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/LightningOutgoingType.kt @@ -0,0 +1,109 @@ +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.cloud.cborSerializer +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 +import kotlinx.serialization.decodeFromByteArray + +@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, + // these parts are now obsolete, we now use a dedicated object for channels closing + val closingTxsParts: List = 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 + ) + } + } // + + @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 + ) + } + + } // +} // diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/SpliceCpfpPaymentType.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/SpliceCpfpPaymentType.kt new file mode 100644 index 00000000..7dbf0978 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/SpliceCpfpPaymentType.kt @@ -0,0 +1,37 @@ +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, + ) +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/SpliceOutgoingType.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/SpliceOutgoingType.kt new file mode 100644 index 00000000..5226ea17 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/SpliceOutgoingType.kt @@ -0,0 +1,40 @@ +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, + ) +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/contacts/ContactQueries.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/contacts/ContactQueries.kt new file mode 100644 index 00000000..fcc82728 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/contacts/ContactQueries.kt @@ -0,0 +1,77 @@ +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 kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +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 { + return queries.listContacts().executeAsList() + } + + fun monitorContactsFlow(context: CoroutineContext): Flow> { + 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) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/contacts/SqliteContactsDb.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/contacts/SqliteContactsDb.kt new file mode 100644 index 00000000..fb0cd886 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/contacts/SqliteContactsDb.kt @@ -0,0 +1,171 @@ +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>(emptyList()) + val contactsList = _contactsList.asStateFlow() + + data class ContactIndexes( + val contactsMap: Map, + val offersMap: Map, + val publicKeysMap: Map, + val addressesMap: Map, + ) + + @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() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/appDb/v7/AfterVersion7.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/appDb/v7/AfterVersion7.kt new file mode 100644 index 00000000..ecf266df --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/appDb/v7/AfterVersion7.kt @@ -0,0 +1,353 @@ +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 { + + 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) { + + 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) { + + 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 { + + 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) { + + 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) { + + 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 +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/AfterVersion10.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/AfterVersion10.kt new file mode 100644 index 00000000..d3b72bcc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/AfterVersion10.kt @@ -0,0 +1,97 @@ +/* + * 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.* +import fr.acinq.phoenix.db.migrations.v10.types.mapIncomingPaymentFromV10 +import fr.acinq.phoenix.utils.extensions.deriveUUID +import fr.acinq.lightning.serialization.payment.Serialization +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)) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/AbstractStringSerializer.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/AbstractStringSerializer.kt new file mode 100644 index 00000000..68bea6d6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/AbstractStringSerializer.kt @@ -0,0 +1,40 @@ +/* + * 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( + name: String, + private val toString: (T) -> String, + private val fromString: (String) -> T +) : KSerializer { + 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()) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/ByteVectorSerializer.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/ByteVectorSerializer.kt new file mode 100644 index 00000000..45cdd144 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/ByteVectorSerializer.kt @@ -0,0 +1,40 @@ +/* + * 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.json + +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.ByteVector64 + + +object ByteVector32Serializer : AbstractStringSerializer( + name = "ByteVector32", + toString = ByteVector32::toHex, + fromString = ::ByteVector32 +) + +object ByteVector64Serializer : AbstractStringSerializer( + name = "ByteVector64", + toString = ByteVector64::toHex, + fromString = ::ByteVector64 +) + +object ByteVectorSerializer : AbstractStringSerializer( + name = "ByteVector", + toString = ByteVector::toHex, + fromString = ::ByteVector +) diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/MilliSatoshiSerializer.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/MilliSatoshiSerializer.kt new file mode 100644 index 00000000..5dc4e117 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/MilliSatoshiSerializer.kt @@ -0,0 +1,42 @@ +/* + * 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.json + +import fr.acinq.lightning.MilliSatoshi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +object MilliSatoshiSerializer : KSerializer { + // we are using a surrogate for legacy reasons. + @Serializable + private data class MilliSatoshiSurrogate(val msat: Long) + + override val descriptor: SerialDescriptor = MilliSatoshiSurrogate.serializer().descriptor + + override fun serialize(encoder: Encoder, value: MilliSatoshi) { + val surrogate = MilliSatoshiSurrogate(msat = value.msat) + return encoder.encodeSerializableValue(MilliSatoshiSurrogate.serializer(), surrogate) + } + + override fun deserialize(decoder: Decoder): MilliSatoshi { + val surrogate = decoder.decodeSerializableValue(MilliSatoshiSurrogate.serializer()) + return MilliSatoshi(msat = surrogate.msat) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/OutpointSerializer.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/OutpointSerializer.kt new file mode 100644 index 00000000..4902b47e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/OutpointSerializer.kt @@ -0,0 +1,30 @@ +/* + * 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.json + +import fr.acinq.bitcoin.OutPoint +import fr.acinq.bitcoin.TxHash + +class OutpointSerializer : AbstractStringSerializer( + name = "Outpoint", + fromString = { serialized -> + serialized.split(":").let { + OutPoint(hash = TxHash(it[0]), index = it[1].toLong()) + } + }, + toString = { outpoint -> "${outpoint.hash}:${outpoint.index}" } +) diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/SatoshiSerializer.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/SatoshiSerializer.kt new file mode 100644 index 00000000..e7017943 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/SatoshiSerializer.kt @@ -0,0 +1,37 @@ +/* + * 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 fr.acinq.bitcoin.Satoshi +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 + +object SatoshiSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Satoshi", PrimitiveKind.LONG) + + override fun serialize(encoder: Encoder, value: Satoshi) { + encoder.encodeLong(value.toLong()) + } + + override fun deserialize(decoder: Decoder): Satoshi { + return Satoshi(decoder.decodeLong()) + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/TxIdSerializer.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/TxIdSerializer.kt new file mode 100644 index 00000000..d63c511a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/TxIdSerializer.kt @@ -0,0 +1,25 @@ +/* + * 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.json + +import fr.acinq.bitcoin.TxId + +object TxIdSerializer : AbstractStringSerializer( + name = "TxId", + toString = TxId::toString, + fromString = ::TxId +) diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/UUIDSerializer.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/UUIDSerializer.kt new file mode 100644 index 00000000..aac8214d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/UUIDSerializer.kt @@ -0,0 +1,42 @@ +/* + * 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.json + +import fr.acinq.lightning.utils.UUID +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + + +object UUIDSerializer : KSerializer { + @Serializable + private data class UUIDSurrogate(val mostSignificantBits: Long, val leastSignificantBits: Long) + + override val descriptor: SerialDescriptor = UUIDSurrogate.serializer().descriptor + + override fun serialize(encoder: Encoder, value: UUID) { + val surrogate = UUIDSurrogate(value.mostSignificantBits, value.leastSignificantBits) + return encoder.encodeSerializableValue(UUIDSurrogate.serializer(), surrogate) + } + + override fun deserialize(decoder: Decoder): UUID { + val surrogate = decoder.decodeSerializableValue(UUIDSurrogate.serializer()) + return UUID(surrogate.mostSignificantBits, surrogate.leastSignificantBits) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/types/IncomingTypes.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/types/IncomingTypes.kt new file mode 100644 index 00000000..144c2a12 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/types/IncomingTypes.kt @@ -0,0 +1,496 @@ +/* + * 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:UseSerializers( + SatoshiSerializer::class, + MilliSatoshiSerializer::class, + ByteVectorSerializer::class, + ByteVector32Serializer::class, + UUIDSerializer::class, + OutpointSerializer::class, +) + +package fr.acinq.phoenix.db.migrations.v10.types + +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.OutPoint +import fr.acinq.bitcoin.Satoshi +import fr.acinq.bitcoin.TxId +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.db.* +import fr.acinq.lightning.payment.Bolt11Invoice +import fr.acinq.lightning.payment.OfferPaymentMetadata +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.phoenix.db.migrations.v11.types.liquidityads.FundingFeeData +import fr.acinq.phoenix.db.migrations.v10.json.ByteVector32Serializer +import fr.acinq.phoenix.db.migrations.v10.json.ByteVectorSerializer +import fr.acinq.phoenix.db.migrations.v10.json.MilliSatoshiSerializer +import fr.acinq.phoenix.db.migrations.v10.json.OutpointSerializer +import fr.acinq.phoenix.db.migrations.v10.json.SatoshiSerializer +import fr.acinq.phoenix.db.migrations.v10.json.UUIDSerializer +import fr.acinq.phoenix.db.migrations.v11.types.liquidityads.FundingFeeData.Companion.asCanonical +import fr.acinq.phoenix.utils.extensions.deriveUUID +import io.ktor.utils.io.charsets.Charsets +import io.ktor.utils.io.core.String +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.builtins.SetSerializer +import kotlinx.serialization.json.Json + + +private enum class IncomingReceivedWithTypeVersion { + @Deprecated("Not used anymore, received-with is now a list of payment parts") + NEW_CHANNEL_V0, + + @Deprecated("Not used anymore, received-with is now a list of payment parts") + LIGHTNING_PAYMENT_V0, + + // multiparts payments are when receivedWith is a set of parts (new channel and htlcs) + @Deprecated( + "MULTIPARTS_V0 had an issue where the incoming amount of pay-to-open (new channels over LN) contained the fee, " + + "instead of only the pushed amount. V1 fixes this by convention, when deserializing the object. No new [IncomingReceivedWithData.Part.xxx.V1] is needed." + ) + MULTIPARTS_V0, + MULTIPARTS_V1, +} + +private sealed class IncomingReceivedWithData { + + @Deprecated("Not used anymore, received-with is now a list of payment parts") + sealed class NewChannel : IncomingReceivedWithData() { + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.IncomingReceivedWithData.NewChannel.V0") + @Suppress("DEPRECATION") + data class V0( + @Serializable val fees: MilliSatoshi, + @Serializable val channelId: ByteVector32? + ) : NewChannel() + } + + @Deprecated("Not used anymore, received-with is now a list of payment parts") + sealed class LightningPayment : IncomingReceivedWithData() { + @Serializable + @SerialName("LIGHTNING_PAYMENT_V0") + @Suppress("DEPRECATION") + object V0 : LightningPayment() + } + + @Serializable + sealed class Part : IncomingReceivedWithData() { + + sealed class Htlc : Part() { + @Deprecated("Replaced by [Htlc.V1], which supports the liquidity ads funding fee") + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.IncomingReceivedWithData.Part.Htlc.V0") + data class V0( + val amount: MilliSatoshi, + val channelId: ByteVector32, + val htlcId: Long + ) : Htlc() + + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.IncomingReceivedWithData.Part.Htlc.V1") + data class V1( + val amountReceived: MilliSatoshi, + val channelId: ByteVector32, + val htlcId: Long, + val fundingFee: FundingFeeData?, + ) : Htlc() + } + + sealed class NewChannel : Part() { + @Deprecated("Legacy type. Use V1 instead for new parts, with the new `id` field.") + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.IncomingReceivedWithData.Part.NewChannel.V0") + data class V0( + val amount: MilliSatoshi, + val fees: MilliSatoshi, + val channelId: ByteVector32? + ) : NewChannel() + + /** V1 contains a new `id` field that ensure that each [NewChannel] is unique. Old V0 data will use a random UUID to respect the [IncomingPayment.ReceivedWith.NewChannel] interface. */ + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.IncomingReceivedWithData.Part.NewChannel.V1") + data class V1( + val id: UUID, + val amount: MilliSatoshi, + val fees: MilliSatoshi, + val channelId: ByteVector32? + ) : NewChannel() + + /** V2 supports dual funding. New fields: service/miningFees, channel id, funding tx id, and the confirmation/lock timestamps. Id is removed. */ + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.IncomingReceivedWithData.Part.NewChannel.V2") + data class V2( + val amount: MilliSatoshi, + val serviceFee: MilliSatoshi, + val miningFee: Satoshi, + val channelId: ByteVector32, + val txId: ByteVector32, + val confirmedAt: Long?, + val lockedAt: Long?, + ) : NewChannel() + } + + sealed class SpliceIn : Part() { + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.IncomingReceivedWithData.Part.SpliceIn.V0") + data class V0( + val amount: MilliSatoshi, + val serviceFee: MilliSatoshi, + val miningFee: Satoshi, + val channelId: ByteVector32, + val txId: ByteVector32, + val confirmedAt: Long?, + val lockedAt: Long?, + ) : SpliceIn() + } + + sealed class FeeCredit : Part() { + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.IncomingReceivedWithData.Part.FeeCredit.V0") + data class V0( + val amount: MilliSatoshi + ) : FeeCredit() + } + } + + companion object { + /** Deserializes a received-with blob from the database using the given typeversion. */ + fun deserialize( + typeVersion: IncomingReceivedWithTypeVersion, + blob: ByteArray, + ): List = @Suppress("DEPRECATION") when (typeVersion) { + IncomingReceivedWithTypeVersion.LIGHTNING_PAYMENT_V0 -> listOf(LightningPayment.V0) + IncomingReceivedWithTypeVersion.NEW_CHANNEL_V0 -> listOf(Json.decodeFromString(NewChannel.V0.serializer(), String(bytes = blob, charset = Charsets.UTF_8))) + IncomingReceivedWithTypeVersion.MULTIPARTS_V0 -> Json.decodeFromString(SetSerializer(Part.serializer()), String(bytes = blob, charset = Charsets.UTF_8)).toList() + IncomingReceivedWithTypeVersion.MULTIPARTS_V1 -> Json.decodeFromString(SetSerializer(Part.serializer()), String(bytes = blob, charset = Charsets.UTF_8)).toList() + } + } +} + +private enum class IncomingOriginTypeVersion { + INVOICE_V0, + SWAPIN_V0, + ONCHAIN_V0, + OFFER_V0, +} + +private sealed class IncomingOriginData { + + sealed class Invoice : IncomingOriginData() { + @Serializable + data class V0(val paymentRequest: String) : Invoice() + } + + /** used for the old trusted swap-in mechanism */ + sealed class SwapIn : IncomingOriginData() { + @Serializable + data class V0(val address: String?) : SwapIn() + } + + /** Used for trustless swap-ins */ + sealed class OnChain : IncomingOriginData() { + @Serializable + data class V0(val txId: ByteVector32, val outpoints: List) : OnChain() + } + + sealed class Offer : IncomingOriginData() { + @Serializable + data class V0(val encodedMetadata: ByteVector) : Offer() + } + + companion object { + fun deserialize(typeVersion: IncomingOriginTypeVersion, blob: ByteArray): IncomingOriginData = + when (typeVersion) { + IncomingOriginTypeVersion.INVOICE_V0 -> Json.decodeFromString(blob.decodeToString()) + IncomingOriginTypeVersion.SWAPIN_V0 -> Json.decodeFromString(blob.decodeToString()) + IncomingOriginTypeVersion.ONCHAIN_V0 -> Json.decodeFromString(blob.decodeToString()) + IncomingOriginTypeVersion.OFFER_V0 -> Json.decodeFromString(blob.decodeToString()) + } + } +} + +@Suppress("DEPRECATION") +private fun mapLightningIncomingPaymentPart(part: IncomingReceivedWithData, receivedAt: Long, receivedAmountFallback: MilliSatoshi?): LightningIncomingPayment.Part = when (part) { + is IncomingReceivedWithData.LightningPayment.V0 -> LightningIncomingPayment.Part.Htlc( + amountReceived = receivedAmountFallback ?: 0.msat, + channelId = ByteVector32.Zeroes, + htlcId = 0L, + fundingFee = null, + receivedAt = receivedAt + ) + is IncomingReceivedWithData.Part.Htlc.V0 -> LightningIncomingPayment.Part.Htlc( + amountReceived = part.amount, + channelId = part.channelId, + htlcId = part.htlcId, + fundingFee = null, + receivedAt = receivedAt + ) + is IncomingReceivedWithData.Part.Htlc.V1 -> LightningIncomingPayment.Part.Htlc( + amountReceived = part.amountReceived, + channelId = part.channelId, + htlcId = part.htlcId, + fundingFee = part.fundingFee?.asCanonical(), + receivedAt = receivedAt + ) + is IncomingReceivedWithData.Part.FeeCredit.V0 -> LightningIncomingPayment.Part.FeeCredit( + amountReceived = part.amount, + receivedAt = receivedAt + ) + else -> error("unexpected part=$part") +} + + +@Suppress("DEPRECATION") +fun mapIncomingPaymentFromV10( + payment_hash: ByteArray, + preimage: ByteArray, + created_at: Long, + origin_type: String, + origin_blob: ByteArray, + received_amount_msat: Long?, + received_at: Long?, + received_with_type: String?, + received_with_blob: ByteArray?, +): IncomingPayment { + val origin = IncomingOriginData.deserialize(IncomingOriginTypeVersion.valueOf(origin_type), origin_blob) + val parts = when { + received_with_type != null && received_with_blob != null -> IncomingReceivedWithData.deserialize(IncomingReceivedWithTypeVersion.valueOf(received_with_type), received_with_blob) + else -> emptyList() + } + return when { + received_at == null && origin is IncomingOriginData.Invoice.V0 -> + Bolt11IncomingPayment( + preimage = ByteVector32(preimage), + paymentRequest = Bolt11Invoice.read(origin.paymentRequest).get(), + parts = emptyList(), + createdAt = created_at + ) + received_at == null && origin is IncomingOriginData.Offer.V0 -> + Bolt12IncomingPayment( + preimage = ByteVector32(preimage), + metadata = OfferPaymentMetadata.decode(origin.encodedMetadata), + parts = emptyList(), + createdAt = created_at + ) + received_at != null && origin is IncomingOriginData.Invoice.V0 && parts.all { it is IncomingReceivedWithData.LightningPayment } -> + Bolt11IncomingPayment( + preimage = ByteVector32(preimage), + paymentRequest = Bolt11Invoice.read(origin.paymentRequest).get(), + parts = parts.map { mapLightningIncomingPaymentPart(it, received_at, received_amount_msat?.msat) }, + createdAt = created_at + ) + received_at != null && origin is IncomingOriginData.Invoice.V0 && parts.all { it is IncomingReceivedWithData.Part.Htlc || it is IncomingReceivedWithData.Part.FeeCredit } -> + Bolt11IncomingPayment( + preimage = ByteVector32(preimage), + paymentRequest = Bolt11Invoice.read(origin.paymentRequest).get(), + parts = parts.map { mapLightningIncomingPaymentPart(it, received_at, received_amount_msat?.msat) }, + createdAt = created_at + ) + received_at != null && origin is IncomingOriginData.Offer.V0 && parts.all { it is IncomingReceivedWithData.Part.Htlc || it is IncomingReceivedWithData.Part.FeeCredit } -> + Bolt12IncomingPayment( + preimage = ByteVector32(preimage), + metadata = OfferPaymentMetadata.decode(origin.encodedMetadata), + parts = parts.map { mapLightningIncomingPaymentPart(it, received_at, received_amount_msat?.msat) }, + createdAt = created_at + ) + received_at != null && (origin is IncomingOriginData.Invoice || origin is IncomingOriginData.Offer) && parts.any { it is IncomingReceivedWithData.Part.SpliceIn || it is IncomingReceivedWithData.Part.NewChannel || it is IncomingReceivedWithData.NewChannel } -> + LegacyPayToOpenIncomingPayment( + paymentPreimage = ByteVector32(preimage), + origin = when (origin) { + is IncomingOriginData.Invoice.V0 -> LegacyPayToOpenIncomingPayment.Origin.Invoice(Bolt11Invoice.read(origin.paymentRequest).get()) + is IncomingOriginData.Offer.V0 -> LegacyPayToOpenIncomingPayment.Origin.Offer(OfferPaymentMetadata.decode(origin.encodedMetadata)) + else -> error("impossible") + }, + parts = parts.mapNotNull { + when (it) { + is IncomingReceivedWithData.Part.Htlc.V0 -> LegacyPayToOpenIncomingPayment.Part.Lightning( + amountReceived = it.amount, + channelId = it.channelId, + htlcId = it.htlcId + ) + is IncomingReceivedWithData.Part.Htlc.V1 -> LegacyPayToOpenIncomingPayment.Part.Lightning( + amountReceived = it.amountReceived, + channelId = it.channelId, + htlcId = it.htlcId + ) + is IncomingReceivedWithData.NewChannel.V0 -> LegacyPayToOpenIncomingPayment.Part.OnChain( + amountReceived = received_amount_msat?.msat ?: 0.msat, + serviceFee = it.fees, + miningFee = 0.sat, + channelId = it.channelId ?: ByteVector32.Zeroes, + txId = TxId(ByteVector32.Zeroes), + confirmedAt = received_at, + lockedAt = received_at, + ) + is IncomingReceivedWithData.Part.NewChannel.V0 -> LegacyPayToOpenIncomingPayment.Part.OnChain( + amountReceived = when { + origin_type == IncomingOriginTypeVersion.SWAPIN_V0.name -> it.amount + received_with_type == IncomingReceivedWithTypeVersion.MULTIPARTS_V0.name -> it.amount - it.fees + else -> it.amount + }, + serviceFee = it.fees, + miningFee = 0.sat, + channelId = it.channelId ?: ByteVector32.Zeroes, + txId = TxId(ByteVector32.Zeroes), + confirmedAt = 0, + lockedAt = 0, + ) + is IncomingReceivedWithData.Part.NewChannel.V1 -> LegacyPayToOpenIncomingPayment.Part.OnChain( + amountReceived = it.amount, + serviceFee = it.fees, + miningFee = 0.sat, + channelId = it.channelId ?: ByteVector32.Zeroes, + txId = TxId(ByteVector32.Zeroes), + confirmedAt = received_at, + lockedAt = received_at, + ) + is IncomingReceivedWithData.Part.NewChannel.V2 -> LegacyPayToOpenIncomingPayment.Part.OnChain( + amountReceived = it.amount, + serviceFee = it.serviceFee, + miningFee = it.miningFee, + channelId = it.channelId, + txId = TxId(it.txId), + confirmedAt = it.confirmedAt, + lockedAt = it.lockedAt, + ) + is IncomingReceivedWithData.Part.SpliceIn.V0 -> LegacyPayToOpenIncomingPayment.Part.OnChain( + amountReceived = it.amount, + serviceFee = it.serviceFee, + miningFee = it.miningFee, + channelId = it.channelId, + txId = TxId(it.txId), + confirmedAt = it.confirmedAt, + lockedAt = it.lockedAt, + ) + IncomingReceivedWithData.LightningPayment.V0 -> null // we have no detail info, and there is at least another on-chain part, so we can just ignore + is IncomingReceivedWithData.Part.FeeCredit.V0 -> null // cannot have a mix of pay-to-open + fee-credit + } + }, + createdAt = created_at, + completedAt = received_at + ) + received_at != null && origin is IncomingOriginData.OnChain.V0 && parts.all { it is IncomingReceivedWithData.Part.NewChannel } -> NewChannelIncomingPayment( + id = ByteVector32(payment_hash).deriveUUID() , + amountReceived = parts.filterIsInstance() + .map { + when (it) { + is IncomingReceivedWithData.Part.NewChannel.V0 -> it.amount + is IncomingReceivedWithData.Part.NewChannel.V1 -> it.amount + is IncomingReceivedWithData.Part.NewChannel.V2 -> it.amount + } + }.sum(), + miningFee = parts.filterIsInstance().map { + when (it) { + is IncomingReceivedWithData.Part.NewChannel.V0 -> 0.sat + is IncomingReceivedWithData.Part.NewChannel.V1 -> 0.sat + is IncomingReceivedWithData.Part.NewChannel.V2 -> it.miningFee + } + }.sum(), + serviceFee = parts.filterIsInstance().map { + when (it) { + is IncomingReceivedWithData.Part.NewChannel.V0 -> it.fees + is IncomingReceivedWithData.Part.NewChannel.V1 -> it.fees + is IncomingReceivedWithData.Part.NewChannel.V2 -> it.serviceFee + } + }.sum(), + liquidityPurchase = null, // will be populated in migration v11 + channelId = parts.filterIsInstance().map { + when (it) { + is IncomingReceivedWithData.Part.NewChannel.V0 -> it.channelId ?: ByteVector32.Zeroes + is IncomingReceivedWithData.Part.NewChannel.V1 -> it.channelId ?: ByteVector32.Zeroes + is IncomingReceivedWithData.Part.NewChannel.V2 -> it.channelId + } + }.first(), + txId = TxId(origin.txId), + localInputs = origin.outpoints.toSet(), + createdAt = created_at, + confirmedAt = parts.filterIsInstance().map { + when (it) { + is IncomingReceivedWithData.Part.NewChannel.V0 -> received_at + is IncomingReceivedWithData.Part.NewChannel.V1 -> received_at + is IncomingReceivedWithData.Part.NewChannel.V2 -> it.confirmedAt + } + }.first(), + lockedAt = parts.filterIsInstance().map { + when (it) { + is IncomingReceivedWithData.Part.NewChannel.V0 -> received_at + is IncomingReceivedWithData.Part.NewChannel.V1 -> received_at + is IncomingReceivedWithData.Part.NewChannel.V2 -> it.lockedAt + } + }.first(), + ) + received_at != null && origin is IncomingOriginData.OnChain.V0 && parts.all { it is IncomingReceivedWithData.Part.SpliceIn } -> SpliceInIncomingPayment( + id = ByteVector32(payment_hash).deriveUUID(), + amountReceived = parts.filterIsInstance().map { + when (it) { + is IncomingReceivedWithData.Part.SpliceIn.V0 -> it.amount + } + }.sum(), + miningFee = parts.filterIsInstance().map { + when (it) { + is IncomingReceivedWithData.Part.SpliceIn.V0 -> it.miningFee + } + }.sum(), + liquidityPurchase = null, + channelId = parts.filterIsInstance().map { + when (it) { + is IncomingReceivedWithData.Part.SpliceIn.V0 -> it.channelId + } + }.first(), + txId = TxId(origin.txId), + localInputs = origin.outpoints.toSet(), + createdAt = created_at, + confirmedAt = parts.filterIsInstance().map { + when (it) { + is IncomingReceivedWithData.Part.SpliceIn.V0 -> it.confirmedAt + } + }.first(), + lockedAt = parts.filterIsInstance().map { + when (it) { + is IncomingReceivedWithData.Part.SpliceIn.V0 -> it.lockedAt + } + }.first(), + ) + received_at != null && origin is IncomingOriginData.SwapIn.V0 && parts.all { it is IncomingReceivedWithData.Part.NewChannel } -> LegacySwapInIncomingPayment( + id = ByteVector32(payment_hash).deriveUUID(), + amountReceived = parts.filterIsInstance().map { + when (it) { + is IncomingReceivedWithData.Part.NewChannel.V0 -> it.amount + is IncomingReceivedWithData.Part.NewChannel.V1 -> it.amount + is IncomingReceivedWithData.Part.NewChannel.V2 -> it.amount + } + }.sum(), + fees = parts.filterIsInstance().map { + when (it) { + is IncomingReceivedWithData.Part.NewChannel.V0 -> it.fees + is IncomingReceivedWithData.Part.NewChannel.V1 -> it.fees + is IncomingReceivedWithData.Part.NewChannel.V2 -> it.serviceFee + } + }.sum(), + address = origin.address, + createdAt = created_at, + completedAt = received_at + ) + else -> TODO("unsupported payment origin=${origin::class} parts=$parts") + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/AfterVersion11.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/AfterVersion11.kt new file mode 100644 index 00000000..e5389c03 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/AfterVersion11.kt @@ -0,0 +1,477 @@ +/* + * 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.v11 + +import app.cash.sqldelight.EnumColumnAdapter +import app.cash.sqldelight.TransacterImpl +import app.cash.sqldelight.db.AfterVersion +import app.cash.sqldelight.db.QueryResult +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.TxId +import fr.acinq.lightning.db.ChannelCloseOutgoingPayment +import fr.acinq.lightning.db.IncomingPayment +import fr.acinq.lightning.db.LightningIncomingPayment +import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.lightning.db.NewChannelIncomingPayment +import fr.acinq.lightning.db.OnChainIncomingPayment +import fr.acinq.lightning.db.OnChainOutgoingPayment +import fr.acinq.lightning.db.OutgoingPayment +import fr.acinq.lightning.serialization.payment.Serialization +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.toByteVector32 +import fr.acinq.phoenix.db.migrations.v11.queries.ChannelCloseOutgoingQueries +import fr.acinq.phoenix.db.migrations.v11.queries.InboundLiquidityQueries +import fr.acinq.phoenix.db.migrations.v11.queries.LightningOutgoingQueries +import fr.acinq.phoenix.db.migrations.v11.queries.SpliceCpfpOutgoingQueries +import fr.acinq.phoenix.db.migrations.v11.queries.SpliceOutgoingQueries +import fr.acinq.phoenix.db.migrations.v11.types.OutgoingDetailsTypeVersion +import fr.acinq.phoenix.db.migrations.v11.types.OutgoingPartClosingInfoTypeVersion +import fr.acinq.phoenix.db.migrations.v11.types.OutgoingPartStatusTypeVersion +import fr.acinq.phoenix.db.migrations.v11.types.OutgoingStatusTypeVersion +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 fr.acinq.phoenix.utils.extensions.deriveUUID +import fr.acinq.phoenix.utils.extensions.toByteArray + +@OptIn(ExperimentalStdlibApi::class) +fun AfterVersion11(onError: (String) -> Unit) = AfterVersion(11) { driver -> + + fun insertPayment(payment: OutgoingPayment) { + driver.execute( + identifier = null, + sql = "INSERT INTO payments_outgoing (id, payment_hash, tx_id, created_at, completed_at, succeeded_at, data) VALUES (?, ?, ?, ?, ?, ?, ?)", + parameters = 7 + ) { + val (paymentHash, txId) = when (payment) { + is LightningOutgoingPayment -> payment.paymentHash to null + is OnChainOutgoingPayment -> null to payment.txId + } + bindBytes(0, payment.id.toByteArray()) + bindBytes(1, paymentHash?.toByteArray()) + bindBytes(2, txId?.value?.toByteArray()) + bindLong(3, payment.createdAt) + bindLong(4, payment.completedAt) + bindLong(5, payment.succeededAt) + bindBytes(6, Serialization.serialize(payment)) + } + } + + val (lightningOutgoingPayments, channelCloseOutgoingPayments) = driver.executeQuery( + identifier = null, + sql = """ +|SELECT parent.id, +| parent.recipient_amount_msat, +| parent.recipient_node_id, +| parent.payment_hash, +| parent.details_type, +| parent.details_blob, +| parent.created_at, +| parent.completed_at, +| parent.status_type, +| parent.status_blob, +| -- lightning parts +| lightning_parts.part_id AS lightning_part_id, +| lightning_parts.part_amount_msat AS lightning_part_amount_msat, +| lightning_parts.part_route AS lightning_part_route, +| lightning_parts.part_created_at AS lightning_part_created_at, +| lightning_parts.part_completed_at AS lightning_part_completed_at, +| lightning_parts.part_status_type AS lightning_part_status_type, +| lightning_parts.part_status_blob AS lightning_part_status_blob, +| -- closing tx parts +| closing_parts.part_id AS closingtx_part_id, +| closing_parts.part_tx_id AS closingtx_tx_id, +| closing_parts.part_amount_sat AS closingtx_amount_sat, +| closing_parts.part_closing_info_type AS closingtx_info_type, +| closing_parts.part_closing_info_blob AS closingtx_info_blob, +| closing_parts.part_created_at AS closingtx_created_at +| FROM outgoing_payments AS parent +| LEFT OUTER JOIN outgoing_payment_parts AS lightning_parts ON lightning_parts.part_parent_id = parent.id +| LEFT OUTER JOIN outgoing_payment_closing_tx_parts AS closing_parts ON closing_parts.part_parent_id = parent.id +""".trimMargin(), + parameters = 0, + mapper = { cursor -> + val lightningOutgoingPayments = mutableListOf() + val channelCloseOutgoingPayments = mutableListOf() + while (cursor.next().value) { + val payment = LightningOutgoingQueries.mapLightningOutgoingPayment( + cursor.getString(0)!!, + cursor.getLong(1)!!, + cursor.getString(2)!!, + cursor.getBytes(3)!!, + OutgoingDetailsTypeVersion.valueOf(cursor.getString(4)!!), + cursor.getBytes(5)!!, + cursor.getLong(6)!!, + cursor.getLong(7), + cursor.getString(8)?.let { OutgoingStatusTypeVersion.valueOf(it) }, + cursor.getBytes(9), + cursor.getString(10), + cursor.getLong(11), + cursor.getString(12) + ?.let { LightningOutgoingQueries.hopDescAdapter.decode(it) }, + cursor.getLong(13), + cursor.getLong(14), + cursor.getString(15)?.let { OutgoingPartStatusTypeVersion.valueOf(it) }, + cursor.getBytes(16), + cursor.getString(17), + cursor.getBytes(18), + cursor.getLong(19), + cursor.getString(20) + ?.let { OutgoingPartClosingInfoTypeVersion.valueOf(it) }, + cursor.getBytes(21), + cursor.getLong(22), + ) + + when (payment) { + is LightningOutgoingPayment -> lightningOutgoingPayments.add(payment) + is ChannelCloseOutgoingPayment -> channelCloseOutgoingPayments.add(payment) + else -> error("impossible") + } + } + QueryResult.Value(lightningOutgoingPayments.toList() to channelCloseOutgoingPayments.toList()) + } + ).value + + /** Group a list of lightning outgoing payments by parent id and parts. */ + fun groupByRawLightningOutgoing(payments: List) = payments + .takeIf { it.isNotEmpty() } + ?.groupBy { it.id } + ?.values + ?.map { group -> group.first().copy(parts = group.flatMap { it.parts }) } + ?: emptyList() + + groupByRawLightningOutgoing(lightningOutgoingPayments) + .map { insertPayment(it) } + + /** Group a list of channel close outgoing payments by parent id and parts. */ + fun groupByRawChannelCloseOutgoing(payments: List) = payments + .groupBy { it.id } + .values + .map { + it.reduce { close1, close2 -> + close1.copy( + recipientAmount = close1.recipientAmount + close2.recipientAmount, + miningFee = close1.miningFee + close2.miningFee + ) + } + } + + groupByRawChannelCloseOutgoing(channelCloseOutgoingPayments) + .map { insertPayment(it) } + + val incomingPayments = driver.executeQuery( + identifier = null, + sql = "SELECT data FROM payments_incoming", + parameters = 0, + mapper = { cursor -> + val result = buildMap { + while (cursor.next().value) { + val data = cursor.getBytes(0)!! + when(val incomingPayment = Serialization.deserialize(data).getOrNull()) { + null -> onError("(v11) cannot migrate legacy incoming data=${data.toHexString()}") + is LightningIncomingPayment -> + when (val txId = incomingPayment.parts + .filterIsInstance() + .firstNotNullOfOrNull { it.fundingFee?.fundingTxId }) { + is TxId -> put(txId, incomingPayment) + else -> {} + } + is NewChannelIncomingPayment -> put(incomingPayment.txId, incomingPayment) + else -> {} + } + } + } + QueryResult.Value(result) + } + ).value + + driver.executeQuery( + identifier = null, + sql = "SELECT id, mining_fees_sat, channel_id, tx_id, lease_type, lease_blob, created_at, confirmed_at, locked_at FROM inbound_liquidity_outgoing_payments", + parameters = 0, + mapper = { cursor -> + while (cursor.next().value) { + + val txId = TxId(cursor.getBytes(3)!!.toByteVector32()) + val (updatedIncomingPayment, liquidityPayment) = InboundLiquidityQueries.mapPayment( + id = cursor.getString(0)!!, + mining_fees_sat = cursor.getLong(1)!!, + channel_id = cursor.getBytes(2)!!, + tx_id = cursor.getBytes(3)!!, + lease_type = cursor.getString(4)!!, + lease_blob = cursor.getBytes(5)!!, + created_at = cursor.getLong(6)!!, + confirmed_at = cursor.getLong(7), + locked_at = cursor.getLong(8), + incomingPayment = incomingPayments[txId] + ) + + updatedIncomingPayment?.let { + driver.execute( + identifier = null, + sql = "UPDATE payments_incoming SET data=?, tx_id=? WHERE id=?", + parameters = 3 + ) { + bindBytes(0, Serialization.serialize(updatedIncomingPayment)) + bindBytes(1, when (updatedIncomingPayment) { + is LightningIncomingPayment -> updatedIncomingPayment.liquidityPurchaseDetails?.txId + is OnChainIncomingPayment -> updatedIncomingPayment.txId + else -> null + }?.value?.toByteArray()) + bindBytes(2, updatedIncomingPayment.id.toByteArray()) + } + } + + liquidityPayment?.let { insertPayment(liquidityPayment) } + } + QueryResult.Unit + } + ) + + driver.executeQuery( + identifier = null, + sql = "SELECT id, recipient_amount_sat, address, mining_fees_sat, tx_id, channel_id, created_at, confirmed_at, locked_at FROM splice_outgoing_payments", + parameters = 0, + mapper = { cursor -> + while (cursor.next().value) { + val payment = SpliceOutgoingQueries.mapSpliceOutgoingPayment( + id = cursor.getString(0)!!, + recipient_amount_sat = cursor.getLong(1)!!, + address = cursor.getString(2)!!, + mining_fees_sat = cursor.getLong(3)!!, + tx_id = cursor.getBytes(4)!!, + channel_id = cursor.getBytes(5)!!, + created_at = cursor.getLong(6)!!, + confirmed_at = cursor.getLong(7), + locked_at = cursor.getLong(8) + ) + insertPayment(payment) + } + QueryResult.Unit + } + ) + + driver.executeQuery( + identifier = null, + sql = "SELECT id, mining_fees_sat, channel_id, tx_id, created_at, confirmed_at, locked_at FROM splice_cpfp_outgoing_payments", + parameters = 0, + mapper = { cursor -> + while (cursor.next().value) { + val payment = SpliceCpfpOutgoingQueries.mapCpfp( + id = cursor.getString(0)!!, + mining_fees_sat = cursor.getLong(1)!!, + channel_id = cursor.getBytes(2)!!, + tx_id = cursor.getBytes(3)!!, + created_at = cursor.getLong(4)!!, + confirmed_at = cursor.getLong(5), + locked_at = cursor.getLong(6) + ) + insertPayment(payment) + } + QueryResult.Unit + } + ) + + driver.executeQuery( + identifier = null, + sql = "SELECT id, recipient_amount_sat, address, is_default_address, mining_fees_sat, tx_id, created_at, confirmed_at, locked_at, channel_id, closing_info_type, closing_info_blob FROM channel_close_outgoing_payments", + parameters = 0, + mapper = { cursor -> + while (cursor.next().value) { + val payment = ChannelCloseOutgoingQueries.mapChannelCloseOutgoingPayment( + id = cursor.getString(0)!!, + amount_sat = cursor.getLong(1)!!, + address = cursor.getString(2)!!, + mining_fees_sat = cursor.getLong(3)!!, + is_default_address = cursor.getLong(4)!!, + tx_id = cursor.getBytes(5)!!, + created_at = cursor.getLong(6)!!, + confirmed_at = cursor.getLong(7), + locked_at = cursor.getLong(8), + channel_id = cursor.getBytes(9)!!, + closing_info_type = OutgoingPartClosingInfoTypeVersion.valueOf(cursor.getString(10)!!), + closing_info_blob = cursor.getBytes(11)!! + ) + insertPayment(payment) + } + QueryResult.Unit + } + ) + + val metadataLinks = driver.executeQuery( + identifier = null, + sql = """ + SELECT type, id, lnurl_base_type, lnurl_base_blob, lnurl_description, lnurl_metadata_type, lnurl_metadata_blob, lnurl_successAction_type, lnurl_successAction_blob, user_description, user_notes, modified_at, original_fiat_type, original_fiat_rate + FROM payments_metadata_old + """.trimIndent(), + parameters = 0, + mapper = { cursor -> + val result = buildList { + while (cursor.next().value) { + val type = cursor.getLong(0)!! + val id = cursor.getString(1)!!.let { if (type == 1L) ByteVector32(it).deriveUUID() else UUID.fromString(it) } + val lnurlBase = cursor.getString(2)?.let { t -> cursor.getBytes(3)?.let { LnurlBase.TypeVersion.valueOf(t) to it } } + val lnurlDesc = cursor.getString(4) + val lnurlMetadata = cursor.getString(5)?.let { t -> cursor.getBytes(6)?.let { LnurlMetadata.TypeVersion.valueOf(t) to it } } + val lnurlSuccessAction = cursor.getString(7)?.let { t -> cursor.getBytes(8)?.let { LnurlSuccessAction.TypeVersion.valueOf(t) to it } } + val userDesc = cursor.getString(9) + val userNotes = cursor.getString(10) + val modifiedAt = cursor.getLong(11) + val originalFiat = cursor.getString(12)?.let { t -> cursor.getDouble(13)?.let { t to it }} + + add(id to WalletPaymentMetadataRow(lnurl_base = lnurlBase, lnurl_metadata = lnurlMetadata, lnurl_successAction = lnurlSuccessAction, lnurl_description = lnurlDesc, + original_fiat = originalFiat, user_description = userDesc, user_notes = userNotes, modified_at = modifiedAt)) + } + } + QueryResult.Value(result) + } + ).value + + metadataLinks + .forEach { (paymentId, metadata) -> + driver.execute( + identifier = null, + sql = """ + INSERT INTO payments_metadata ( + payment_id, + lnurl_base_type, lnurl_base_blob, + lnurl_description, + lnurl_metadata_type, lnurl_metadata_blob, + lnurl_successAction_type, lnurl_successAction_blob, + user_description, user_notes, + modified_at, + original_fiat_type, original_fiat_rate) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent(), + parameters = 13 + ) { + bindBytes(0, paymentId.toByteArray()) + bindString(1, metadata.lnurl_base?.first?.let { EnumColumnAdapter().encode(it) }) + bindBytes(2, metadata.lnurl_base?.second) + bindString(3, metadata.lnurl_description) + bindString(4, metadata.lnurl_metadata?.first?.let { EnumColumnAdapter().encode(it) }) + bindBytes(5, metadata.lnurl_metadata?.second) + bindString(6, metadata.lnurl_successAction?.first?.let { EnumColumnAdapter().encode(it) }) + bindBytes(7, metadata.lnurl_successAction?.second) + bindString(8, metadata.user_description) + bindString(9, metadata.user_notes) + bindLong(10, metadata.modified_at) + bindString(11, metadata.original_fiat?.first) + bindDouble(12, metadata.original_fiat?.second) + } + } + + data class OnChainLink(val txId: ByteArray, val paymentId: UUID, val confirmedAt: Long?, val lockedAt: Long?) + + val onChainTxLinks = driver.executeQuery( + identifier = null, + sql = """ + SELECT tx_id, type, id, confirmed_at, locked_at + FROM link_tx_to_payments + """.trimIndent(), + parameters = 0, + mapper = { cursor -> + val result = buildList { + while (cursor.next().value) { + val txId = cursor.getBytes(0)!! + val type = cursor.getLong(1)!! + val id = cursor.getString(2)!!.let { if (type == 1L) ByteVector32(it).deriveUUID() else UUID.fromString(it) } + val confirmedAt = cursor.getLong(3) + val lockedAt = cursor.getLong(4) + add(OnChainLink(txId = txId, paymentId = id, confirmedAt = confirmedAt, lockedAt = lockedAt)) + } + } + QueryResult.Value(result) + } + ).value + + onChainTxLinks + .forEach { onChainTxLink -> + driver.execute( + identifier = null, + sql = """ + INSERT INTO on_chain_txs (payment_id, tx_id, confirmed_at, locked_at) VALUES (?, ?, ?, ?) + """.trimIndent(), + parameters = 4 + ) { + bindBytes(0, onChainTxLink.paymentId.toByteArray()) + bindBytes(1, onChainTxLink.txId) + bindLong(2, onChainTxLink.confirmedAt) + bindLong(3, onChainTxLink.lockedAt) + } + } + + data class MetadataRow( + val unpaddedSize: Long?, + val recordCreation: Long?, + val recordBlob: ByteArray?, + ) + + val cloudMetadata = driver.executeQuery( + identifier = null, + sql = """ + SELECT type, id, unpadded_size, record_creation, record_blob + FROM cloudkit_payments_metadata_old + """.trimIndent(), + parameters = 0, + mapper = { cursor -> + val result = buildList { + while (cursor.next().value) { + val type = cursor.getLong(0)!! + val id = cursor.getString(1)!!.let { if (type == 1L) ByteVector32(it).deriveUUID() else UUID.fromString(it) } + val unpaddedSize = cursor.getLong(2) + val recordCreation = cursor.getLong(3) + val recordBlob = cursor.getBytes(4) + + add(id to MetadataRow(unpaddedSize, recordCreation, recordBlob)) + } + } + QueryResult.Value(result) + } + ).value + + cloudMetadata + .forEach { (paymentId, metadata) -> + driver.execute( + identifier = null, + sql = "INSERT INTO cloudkit_payments_metadata (id, unpadded_size, record_creation, record_blob) VALUES (?, ?, ?, ?)", + parameters = 4 + ) { + bindBytes(0, paymentId.toByteArray()) + bindLong(1, metadata.unpaddedSize) + bindLong(2, metadata.recordCreation) + bindBytes(3, metadata.recordBlob) + } + } + + listOf( + "DROP TABLE outgoing_payment_parts", // Foreign key constraint: must be before `outgoing_payments` + "DROP TABLE outgoing_payment_closing_tx_parts", // Foreign key constraint: must be before `outgoing_payments` + "DROP TABLE outgoing_payments", + "DROP TABLE inbound_liquidity_outgoing_payments", + "DROP TABLE splice_outgoing_payments", + "DROP TABLE splice_cpfp_outgoing_payments", + "DROP TABLE channel_close_outgoing_payments", + "DROP TABLE payments_metadata_old", + "DROP TABLE cloudkit_payments_metadata_old", + "DROP TABLE cloudkit_payments_queue_old", + "DROP TABLE link_tx_to_payments" + ).forEach { sql -> + driver.execute(identifier = null, sql = sql, parameters = 0) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/ChannelCloseOutgoingQueries.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/ChannelCloseOutgoingQueries.kt new file mode 100644 index 00000000..e47153b3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/ChannelCloseOutgoingQueries.kt @@ -0,0 +1,60 @@ +/* + * 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.v11.queries + +import fr.acinq.bitcoin.TxId +import fr.acinq.lightning.db.ChannelCloseOutgoingPayment +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.utils.toByteVector32 +import fr.acinq.phoenix.db.migrations.v11.types.OutgoingPartClosingInfoData +import fr.acinq.phoenix.db.migrations.v11.types.OutgoingPartClosingInfoTypeVersion + +object ChannelCloseOutgoingQueries { + + fun mapChannelCloseOutgoingPayment( + id: String, + amount_sat: Long, + address: String, + is_default_address: Long, + mining_fees_sat: Long, + tx_id: ByteArray, + created_at: Long, + confirmed_at: Long?, + locked_at: Long?, + channel_id: ByteArray, + closing_info_type: OutgoingPartClosingInfoTypeVersion, + closing_info_blob: ByteArray + ): ChannelCloseOutgoingPayment { + return ChannelCloseOutgoingPayment( + id = UUID.fromString(id), + recipientAmount = amount_sat.sat, + address = address, + isSentToDefaultAddress = is_default_address == 1L, + miningFee = mining_fees_sat.sat, + txId = TxId(tx_id), + createdAt = created_at, + confirmedAt = confirmed_at, + lockedAt = locked_at, + channelId = channel_id.toByteVector32(), + closingType = OutgoingPartClosingInfoData.deserialize( + closing_info_type, + closing_info_blob + ), + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/InboundLiquidityQueries.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/InboundLiquidityQueries.kt new file mode 100644 index 00000000..217b6c41 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/InboundLiquidityQueries.kt @@ -0,0 +1,113 @@ +/* + * 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.v11.queries + +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.IncomingPayment +import fr.acinq.lightning.db.LightningIncomingPayment +import fr.acinq.lightning.db.ManualLiquidityPurchasePayment +import fr.acinq.lightning.db.NewChannelIncomingPayment +import fr.acinq.lightning.db.OutgoingPayment +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.utils.toByteVector32 +import fr.acinq.lightning.utils.toMilliSatoshi +import fr.acinq.lightning.wire.LiquidityAds +import fr.acinq.phoenix.db.migrations.v11.types.liquidityads.PurchaseData + +object InboundLiquidityQueries { + + fun mapPayment( + id: String, + mining_fees_sat: Long, + channel_id: ByteArray, + tx_id: ByteArray, + lease_type: String, + lease_blob: ByteArray, + created_at: Long, + confirmed_at: Long?, + locked_at: Long?, + incomingPayment: IncomingPayment? + ): Pair { + val channelId = channel_id.toByteVector32() + val miningFee = mining_fees_sat.sat + val txId = TxId(tx_id) + val purchase = PurchaseData.decodeAsCanonical(lease_type, lease_blob) + + return when (incomingPayment) { + is LightningIncomingPayment -> { + val liquidityPurchaseDetails = LiquidityAds.LiquidityTransactionDetails( + txId = txId, + miningFee = miningFee, + purchase = purchase + ) + val (incomingPayment1, incomingPaymentReceivedAt) = when (incomingPayment) { + is Bolt11IncomingPayment -> incomingPayment.copy( + liquidityPurchaseDetails = liquidityPurchaseDetails + ) to incomingPayment.completedAt + + is Bolt12IncomingPayment -> incomingPayment.copy( + liquidityPurchaseDetails = liquidityPurchaseDetails + ) to incomingPayment.completedAt + + else -> null to null + } + val liquidityPayment = AutomaticLiquidityPurchasePayment( + id = UUID.fromString(id), + miningFee = miningFee, + channelId = channelId, + txId = txId, + liquidityPurchase = purchase, + createdAt = created_at, + confirmedAt = confirmed_at, + lockedAt = locked_at, + incomingPaymentReceivedAt = incomingPaymentReceivedAt + ) + incomingPayment1 to liquidityPayment + } + + is NewChannelIncomingPayment -> { + val incomingPayment1 = + incomingPayment.copy( + miningFee = incomingPayment.miningFee + purchase.fees.miningFee, + serviceFee = purchase.fees.serviceFee.toMilliSatoshi(), + liquidityPurchase = purchase + ) + incomingPayment1 to null + } + + null -> { + val liquidityPayment = ManualLiquidityPurchasePayment( + id = UUID.fromString(id), + miningFee = miningFee, + channelId = channelId, + txId = txId, + liquidityPurchase = purchase, + createdAt = created_at, + confirmedAt = confirmed_at, + lockedAt = locked_at + ) + null to liquidityPayment + } + + else -> error("impossible") + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/LightningOutgoingQueries.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/LightningOutgoingQueries.kt new file mode 100644 index 00000000..14f6d0f5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/LightningOutgoingQueries.kt @@ -0,0 +1,221 @@ +/* + * 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.v11.queries + +import app.cash.sqldelight.ColumnAdapter +import fr.acinq.bitcoin.PublicKey +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.ShortChannelId +import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.lightning.db.OutgoingPayment +import fr.acinq.lightning.utils.* +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.OutgoingPartClosingInfoTypeVersion +import fr.acinq.phoenix.db.migrations.v11.types.OutgoingPartStatusData +import fr.acinq.phoenix.db.migrations.v11.types.OutgoingPartStatusTypeVersion +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 fr.acinq.secp256k1.Hex + +object LightningOutgoingQueries { + + @Suppress("UNUSED_PARAMETER") + private fun mapLightningOutgoingPaymentWithoutParts( + id: String, + recipient_amount_msat: Long, + recipient_node_id: String, + payment_hash: ByteArray, + details_type: OutgoingDetailsTypeVersion, + details_blob: ByteArray, + created_at: Long, + completed_at: Long?, + status_type: OutgoingStatusTypeVersion?, + status_blob: ByteArray? + ): LightningOutgoingPayment { + val details = OutgoingDetailsData.deserialize(details_type, details_blob) + return if (details != null) { + LightningOutgoingPayment( + id = UUID.fromString(id), + recipientAmount = MilliSatoshi(recipient_amount_msat), + recipient = PublicKey.parse(Hex.decode(recipient_node_id)), + details = details, + parts = listOf(), + status = mapPaymentStatus(status_type, status_blob, completed_at), + createdAt = created_at + ) + } else throw IllegalArgumentException("cannot handle closing payment at this stage, use LegacyChannelCloseHelper") + } + + @Suppress("UNUSED_PARAMETER", "DEPRECATION") + fun mapLightningOutgoingPayment( + id: String, + recipient_amount_msat: Long, + recipient_node_id: String, + payment_hash: ByteArray, + details_type: OutgoingDetailsTypeVersion, + details_blob: ByteArray, + created_at: Long, + completed_at: Long?, + status_type: OutgoingStatusTypeVersion?, + status_blob: ByteArray?, + // lightning parts data, may be null + lightning_part_id: String?, + lightning_part_amount_msat: Long?, + lightning_part_route: List?, + lightning_part_created_at: Long?, + lightning_part_completed_at: Long?, + lightning_part_status_type: OutgoingPartStatusTypeVersion?, + lightning_part_status_blob: ByteArray?, + // closing tx parts data, may be null + closingtx_part_id: String?, + closingtx_part_tx_id: ByteArray?, + closingtx_part_amount_sat: Long?, + closingtx_part_closing_info_type: OutgoingPartClosingInfoTypeVersion?, + closingtx_part_closing_info_blob: ByteArray?, + closingtx_part_created_at: Long? + ): OutgoingPayment { + + // handle legacy cases where the outgoing_payments tables would contain the details for channel closing. + // we map these legacy data to the new ChannelCloseOutgoingPayment object, using placeholders when needed. + if (details_type == OutgoingDetailsTypeVersion.CLOSING_V0 || closingtx_part_id != null) { + try { + return LegacyChannelCloseHelper.convertLegacyToChannelClose( + id = UUID.fromString(id), + recipientAmount = recipient_amount_msat.msat, + detailsBlob = if (details_type == OutgoingDetailsTypeVersion.CLOSING_V0) details_blob else null, + statusBlob = if (status_type == OutgoingStatusTypeVersion.SUCCEEDED_ONCHAIN_V0) status_blob else null, + partsAmount = closingtx_part_amount_sat?.sat, + partsTxId = closingtx_part_tx_id?.toByteVector32(), + partsClosingTypeBlob = closingtx_part_closing_info_blob, + confirmedAt = completed_at ?: created_at, + createdAt = created_at, + ) + } catch (_: Exception) { + } + } + + val parts = + if (lightning_part_id != null && lightning_part_amount_msat != null && lightning_part_route != null && lightning_part_created_at != null) { + listOf( + mapLightningPart( + id = lightning_part_id, + amountMsat = lightning_part_amount_msat, + route = lightning_part_route, + createdAt = lightning_part_created_at, + completedAt = lightning_part_completed_at, + statusType = lightning_part_status_type, + statusBlob = lightning_part_status_blob + ) + ) + } else emptyList() + + return mapLightningOutgoingPaymentWithoutParts( + id = id, + recipient_amount_msat = recipient_amount_msat, + recipient_node_id = recipient_node_id, + payment_hash = payment_hash, + details_type = details_type, + details_blob = details_blob, + created_at = created_at, + completed_at = completed_at, + status_type = status_type, + status_blob = status_blob + ).copy( + parts = parts + ) + } + + private fun mapLightningPart( + id: String, + amountMsat: Long, + route: List, + createdAt: Long, + completedAt: Long?, + statusType: OutgoingPartStatusTypeVersion?, + statusBlob: ByteArray? + ): LightningOutgoingPayment.Part { + return LightningOutgoingPayment.Part( + id = UUID.fromString(id), + amount = MilliSatoshi(amountMsat), + route = route, + status = mapLightningPartStatus( + statusType = statusType, + statusBlob = statusBlob, + completedAt = completedAt + ), + createdAt = createdAt + ) + } + + private fun mapPaymentStatus( + statusType: OutgoingStatusTypeVersion?, + statusBlob: ByteArray?, + completedAt: Long?, + ): LightningOutgoingPayment.Status = when { + completedAt == null && statusType == null && statusBlob == null -> LightningOutgoingPayment.Status.Pending + completedAt != null && statusType != null && statusBlob != null -> OutgoingStatusData.deserialize( + statusType, + statusBlob, + completedAt + ) + + else -> throw UnhandledOutgoingStatus(completedAt, statusType, statusBlob) + } + + private fun mapLightningPartStatus( + statusType: OutgoingPartStatusTypeVersion?, + statusBlob: ByteArray?, + completedAt: Long?, + ): LightningOutgoingPayment.Part.Status = when { + completedAt == null && statusType == null && statusBlob == null -> LightningOutgoingPayment.Part.Status.Pending + completedAt != null && statusType != null && statusBlob != null -> OutgoingPartStatusData.deserialize( + statusType, + statusBlob, + completedAt + ) + + else -> throw UnhandledOutgoingPartStatus(statusType, statusBlob, completedAt) + } + + val hopDescAdapter: ColumnAdapter, String> = + object : ColumnAdapter, String> { + override fun decode(databaseValue: String): List = + when { + databaseValue.isEmpty() -> listOf() + else -> databaseValue.split(";").map { hop -> + val els = hop.split(":") + val n1 = PublicKey.parse(Hex.decode(els[0])) + val n2 = PublicKey.parse(Hex.decode(els[1])) + val cid = els[2].takeIf { it.isNotBlank() }?.run { ShortChannelId(this) } + LightningOutgoingPayment.Part.HopDesc(n1, n2, cid) + } + } + + override fun encode(value: List): String = + value.joinToString(";") { + "${it.nodeId}:${it.nextNodeId}:${it.shortChannelId ?: ""}" + } + } +} + +data class UnhandledOutgoingStatus(val completedAt: Long?, val statusTypeVersion: OutgoingStatusTypeVersion?, val statusData: ByteArray?) : + RuntimeException("cannot map outgoing payment status data with completed_at=$completedAt status_type=$statusTypeVersion status=$statusData") + +data class UnhandledOutgoingPartStatus(val status_type: OutgoingPartStatusTypeVersion?, val status_blob: ByteArray?, val completedAt: Long?) : + RuntimeException("cannot map outgoing part status data [ completed_at=$completedAt status_type=$status_type status_blob=$status_blob]") \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/SpliceCpfpOutgoingQueries.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/SpliceCpfpOutgoingQueries.kt new file mode 100644 index 00000000..359d10d9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/SpliceCpfpOutgoingQueries.kt @@ -0,0 +1,46 @@ +/* + * 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.v11.queries + +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 + +object SpliceCpfpOutgoingQueries { + + fun mapCpfp( + id: String, + mining_fees_sat: Long, + channel_id: ByteArray, + tx_id: ByteArray, + created_at: Long, + confirmed_at: Long?, + locked_at: Long? + ): SpliceCpfpOutgoingPayment { + return SpliceCpfpOutgoingPayment( + id = UUID.fromString(id), + miningFee = mining_fees_sat.sat, + channelId = channel_id.toByteVector32(), + txId = TxId(tx_id), + createdAt = created_at, + confirmedAt = confirmed_at, + lockedAt = locked_at + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/SpliceOutgoingQueries.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/SpliceOutgoingQueries.kt new file mode 100644 index 00000000..ba4b94d3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/SpliceOutgoingQueries.kt @@ -0,0 +1,51 @@ +/* + * 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.v11.queries + +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 + +object SpliceOutgoingQueries { + + fun mapSpliceOutgoingPayment( + id: String, + recipient_amount_sat: Long, + address: String, + mining_fees_sat: Long, + tx_id: ByteArray, + channel_id: ByteArray, + created_at: Long, + confirmed_at: Long?, + locked_at: Long? + ): SpliceOutgoingPayment { + return SpliceOutgoingPayment( + id = UUID.fromString(id), + recipientAmount = recipient_amount_sat.sat, + address = address, + miningFee = mining_fees_sat.sat, + channelId = channel_id.toByteVector32(), + txId = TxId(tx_id), + liquidityPurchase = null, + createdAt = created_at, + confirmedAt = confirmed_at, + lockedAt = locked_at + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingDetailsType.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingDetailsType.kt new file mode 100644 index 00000000..882c392d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingDetailsType.kt @@ -0,0 +1,99 @@ +/* + * 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:UseSerializers( + SatoshiSerializer::class, + ByteVector32Serializer::class, +) + +package fr.acinq.phoenix.db.migrations.v11.types + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.Satoshi +import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.lightning.payment.Bolt11Invoice +import fr.acinq.lightning.payment.Bolt12Invoice +import fr.acinq.phoenix.db.migrations.v10.json.ByteVector32Serializer +import fr.acinq.phoenix.db.migrations.v10.json.SatoshiSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.json.Json + +enum class OutgoingDetailsTypeVersion { + NORMAL_V0, + SWAPOUT_V0, + @Deprecated("channel close are now stored in their own table") + CLOSING_V0, + BLINDED_V0, +} + +sealed class OutgoingDetailsData { + + sealed class Normal : OutgoingDetailsData() { + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.OutgoingDetailsData.Normal.V0") + data class V0(val paymentRequest: String) : Normal() + } + + sealed class SwapOut : OutgoingDetailsData() { + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.OutgoingDetailsData.SwapOut.V0") + data class V0(val address: String, val paymentRequest: String, @Serializable val swapOutFee: Satoshi) : SwapOut() + } + + sealed class Blinded : OutgoingDetailsData() { + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.OutgoingDetailsData.Blinded.V0") + data class V0(val paymentRequest: String, val payerKey: String) : Blinded() + } + + // channel close are now stored in their own table + sealed class Closing : OutgoingDetailsData() { + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.OutgoingDetailsData.Closing.V0") + data class V0( + @Serializable val channelId: ByteVector32, + val closingAddress: String, + val isSentToDefaultAddress: Boolean + ) : Closing() + } + + companion object { + @Suppress("DEPRECATION") + /** Deserialize the details of an outgoing payment. Return null if the details is for a legacy channel closing payment (see [deserializeLegacyClosingDetails]). */ + fun deserialize(typeVersion: OutgoingDetailsTypeVersion, blob: ByteArray): LightningOutgoingPayment.Details? = + when (typeVersion) { + OutgoingDetailsTypeVersion.NORMAL_V0 -> Json.decodeFromString(blob.decodeToString()).let { + LightningOutgoingPayment.Details.Normal(Bolt11Invoice.read(it.paymentRequest).get()) + } + OutgoingDetailsTypeVersion.SWAPOUT_V0 -> Json.decodeFromString(blob.decodeToString()).let { + LightningOutgoingPayment.Details.SwapOut(it.address, Bolt11Invoice.read(it.paymentRequest).get(), it.swapOutFee) + } + OutgoingDetailsTypeVersion.CLOSING_V0 -> null + OutgoingDetailsTypeVersion.BLINDED_V0 -> Json.decodeFromString(blob.decodeToString()).let { + LightningOutgoingPayment.Details.Blinded( + paymentRequest = Bolt12Invoice.fromString(it.paymentRequest).get(), + payerKey = PrivateKey.fromHex(it.payerKey), + ) + } + } + + /** Returns the channel closing details from a blob, for backward-compatibility purposes. */ + fun deserializeLegacyClosingDetails(blob: ByteArray): Closing.V0 = Json.decodeFromString(blob.decodeToString()) + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingPartClosingType.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingPartClosingType.kt new file mode 100644 index 00000000..feab4fd9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingPartClosingType.kt @@ -0,0 +1,42 @@ +/* + * 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.v11.types + +import fr.acinq.lightning.db.ChannelCloseOutgoingPayment.ChannelClosingType +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + + +enum class OutgoingPartClosingInfoTypeVersion { + // basic type, containing only a [ChannelClosingType] field + CLOSING_INFO_V0, +} + +sealed class OutgoingPartClosingInfoData { + + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.OutgoingPartClosingInfoData.V0") + data class V0(val closingType: ChannelClosingType) + + companion object { + fun deserialize(typeVersion: OutgoingPartClosingInfoTypeVersion, blob: ByteArray): ChannelClosingType = + when (typeVersion) { + OutgoingPartClosingInfoTypeVersion.CLOSING_INFO_V0 -> Json.decodeFromString(blob.decodeToString()).closingType + } + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingPartStatusType.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingPartStatusType.kt new file mode 100644 index 00000000..441ee03d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingPartStatusType.kt @@ -0,0 +1,96 @@ +/* + * 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:UseSerializers( + ByteVector32Serializer::class, +) + +package fr.acinq.phoenix.db.migrations.v11.types + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.phoenix.db.migrations.v10.json.ByteVector32Serializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.json.Json + + +enum class OutgoingPartStatusTypeVersion { + SUCCEEDED_V0, + // Obsolete, do not use anymore. Failed parts are now typed, with a code and an option string message. + FAILED_V0, + FAILED_V1, +} + +sealed class OutgoingPartStatusData { + + sealed class Succeeded : OutgoingPartStatusData() { + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.OutgoingPartStatusData.Succeeded.V0") + data class V0(@Serializable val preimage: ByteVector32) : Succeeded() + } + + sealed class Failed : OutgoingPartStatusData() { + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.OutgoingPartStatusData.Failed.V0") + data class V0(val remoteFailureCode: Int?, val details: String) : Failed() + + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.OutgoingPartStatusData.Failed.V1") + data class V1(val code: Int, val details: String?) : Failed() + } + + companion object { + fun deserialize( + typeVersion: OutgoingPartStatusTypeVersion, + blob: ByteArray, + completedAt: Long + ): LightningOutgoingPayment.Part.Status = + when (typeVersion) { + OutgoingPartStatusTypeVersion.SUCCEEDED_V0 -> Json.decodeFromString(blob.decodeToString()).let { + LightningOutgoingPayment.Part.Status.Succeeded(it.preimage, completedAt) + } + OutgoingPartStatusTypeVersion.FAILED_V0 -> Json.decodeFromString(blob.decodeToString()).let { + LightningOutgoingPayment.Part.Status.Failed( + failure = LightningOutgoingPayment.Part.Status.Failed.Failure.Uninterpretable(message = it.details), + completedAt = completedAt, + ) + } + OutgoingPartStatusTypeVersion.FAILED_V1 -> Json.decodeFromString(blob.decodeToString()).let { + LightningOutgoingPayment.Part.Status.Failed( + failure = when (it.code) { + 0 -> LightningOutgoingPayment.Part.Status.Failed.Failure.Uninterpretable(it.details ?: "n/a") + 1 -> LightningOutgoingPayment.Part.Status.Failed.Failure.PaymentAmountTooSmall + 2 -> LightningOutgoingPayment.Part.Status.Failed.Failure.PaymentAmountTooBig + 3 -> LightningOutgoingPayment.Part.Status.Failed.Failure.NotEnoughFunds + 4 -> LightningOutgoingPayment.Part.Status.Failed.Failure.NotEnoughFees + 5 -> LightningOutgoingPayment.Part.Status.Failed.Failure.PaymentExpiryTooBig + 6 -> LightningOutgoingPayment.Part.Status.Failed.Failure.TooManyPendingPayments + 7 -> LightningOutgoingPayment.Part.Status.Failed.Failure.ChannelIsSplicing + 8 -> LightningOutgoingPayment.Part.Status.Failed.Failure.ChannelIsClosing + 9 -> LightningOutgoingPayment.Part.Status.Failed.Failure.TemporaryRemoteFailure + 10 -> LightningOutgoingPayment.Part.Status.Failed.Failure.RecipientLiquidityIssue + 11 -> LightningOutgoingPayment.Part.Status.Failed.Failure.RecipientIsOffline + 12 -> LightningOutgoingPayment.Part.Status.Failed.Failure.RecipientRejectedPayment + else -> LightningOutgoingPayment.Part.Status.Failed.Failure.Uninterpretable(it.details ?: "n/a") + }, + completedAt = completedAt, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingStatusType.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingStatusType.kt new file mode 100644 index 00000000..6b1871ec --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingStatusType.kt @@ -0,0 +1,108 @@ +/* + * 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:UseSerializers( + SatoshiSerializer::class, + ByteVector32Serializer::class, +) + +package fr.acinq.phoenix.db.migrations.v11.types + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Satoshi +import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.lightning.payment.FinalFailure +import fr.acinq.phoenix.db.migrations.v10.json.ByteVector32Serializer +import fr.acinq.phoenix.db.migrations.v10.json.SatoshiSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.json.Json + +enum class OutgoingStatusTypeVersion { + SUCCEEDED_OFFCHAIN_V0, + @Deprecated("Use the new SUCCEEDED_ONCHAIN_V1 format. This status was used to store data about channel closing transactions.") + SUCCEEDED_ONCHAIN_V0, + @Deprecated("Starting with splices, we now use SpliceOut or ChannelClose outgoing payments type for on-chain payments") + SUCCEEDED_ONCHAIN_V1, + FAILED_V0, +} + +sealed class OutgoingStatusData { + + sealed class SucceededOffChain : OutgoingStatusData() { + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.OutgoingStatusData.SucceededOffChain.V0") + data class V0(@Serializable val preimage: ByteVector32) : SucceededOffChain() + } + + sealed class SucceededOnChain : OutgoingStatusData() { + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.OutgoingStatusData.SucceededOnChain.V0") + data class V0( + val txIds: List<@Serializable ByteVector32>, + @Serializable val claimed: Satoshi, + val closingType: String + ) : SucceededOnChain() + + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.OutgoingStatusData.SucceededOnChain.V1") + data object V1 : SucceededOnChain() + } + + sealed class Failed : OutgoingStatusData() { + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.OutgoingStatusData.Failed.V0") + data class V0(val reason: String) : Failed() + } + + companion object { + + /** Extract valuable data from old outgoing payments status that represent closing transactions. */ + fun deserializeLegacyClosingStatus(blob: ByteArray): SucceededOnChain.V0 = Json.decodeFromString(blob.decodeToString()) + + fun deserialize(typeVersion: OutgoingStatusTypeVersion, blob: ByteArray, completedAt: Long): LightningOutgoingPayment.Status = + @Suppress("DEPRECATION") + when (typeVersion) { + OutgoingStatusTypeVersion.SUCCEEDED_OFFCHAIN_V0 -> Json.decodeFromString(blob.decodeToString()).let { + LightningOutgoingPayment.Status.Succeeded(it.preimage, completedAt) + } + OutgoingStatusTypeVersion.SUCCEEDED_ONCHAIN_V0, OutgoingStatusTypeVersion.SUCCEEDED_ONCHAIN_V1 -> { + TODO("impossible scenario") + } + OutgoingStatusTypeVersion.FAILED_V0 -> Json.decodeFromString(blob.decodeToString()).let { + LightningOutgoingPayment.Status.Failed(deserializeFinalFailure(it.reason), completedAt) + } + } + + private fun deserializeFinalFailure(failure: String): FinalFailure = when (failure) { + FinalFailure.InvalidPaymentAmount::class.simpleName -> FinalFailure.InvalidPaymentAmount + FinalFailure.InvalidPaymentId::class.simpleName -> FinalFailure.InvalidPaymentId + FinalFailure.NoAvailableChannels::class.simpleName -> FinalFailure.NoAvailableChannels + FinalFailure.InsufficientBalance::class.simpleName -> FinalFailure.InsufficientBalance + FinalFailure.RecipientUnreachable::class.simpleName -> FinalFailure.RecipientUnreachable + FinalFailure.RetryExhausted::class.simpleName -> FinalFailure.RetryExhausted + FinalFailure.WalletRestarted::class.simpleName -> FinalFailure.WalletRestarted + FinalFailure.AlreadyPaid::class.simpleName -> FinalFailure.AlreadyPaid + FinalFailure.ChannelClosing::class.simpleName -> FinalFailure.ChannelClosing + FinalFailure.ChannelOpening::class.simpleName -> FinalFailure.ChannelOpening + FinalFailure.ChannelNotConnected::class.simpleName -> FinalFailure.ChannelNotConnected + FinalFailure.FeaturesNotSupported::class.simpleName -> FinalFailure.FeaturesNotSupported + FinalFailure.AlreadyInProgress::class.simpleName -> FinalFailure.AlreadyInProgress + else -> FinalFailure.UnknownError + } + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/FundingFeeData.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/FundingFeeData.kt new file mode 100644 index 00000000..2310b8bb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/FundingFeeData.kt @@ -0,0 +1,46 @@ +/* + * 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:UseSerializers( + MilliSatoshiSerializer::class, + TxIdSerializer::class, +) + +package fr.acinq.phoenix.db.migrations.v11.types.liquidityads + +import fr.acinq.bitcoin.TxId +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.phoenix.db.migrations.v10.json.MilliSatoshiSerializer +import fr.acinq.phoenix.db.migrations.v10.json.TxIdSerializer +import fr.acinq.lightning.wire.LiquidityAds +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers + +@Serializable +sealed class FundingFeeData { + + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.liquidityads.FundingFeeData.V0") + data class V0(val amount: MilliSatoshi, val fundingTxId: TxId) : FundingFeeData() + + companion object { + fun FundingFeeData.asCanonical(): LiquidityAds.FundingFee = when (this) { + is V0 -> LiquidityAds.FundingFee(amount = amount, fundingTxId = fundingTxId) + } + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/LegacyLeaseData.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/LegacyLeaseData.kt new file mode 100644 index 00000000..1796ef4c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/LegacyLeaseData.kt @@ -0,0 +1,67 @@ +/* + * 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. + */ + +@file:UseSerializers( + ByteVectorSerializer::class, + ByteVector32Serializer::class, + ByteVector64Serializer::class, + SatoshiSerializer::class, + MilliSatoshiSerializer::class +) + +package fr.acinq.phoenix.db.migrations.v11.types.liquidityads + +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector64 +import fr.acinq.bitcoin.Satoshi +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.wire.LiquidityAds +import fr.acinq.phoenix.db.migrations.v10.json.ByteVector32Serializer +import fr.acinq.phoenix.db.migrations.v10.json.ByteVector64Serializer +import fr.acinq.phoenix.db.migrations.v10.json.ByteVectorSerializer +import fr.acinq.phoenix.db.migrations.v10.json.MilliSatoshiSerializer +import fr.acinq.phoenix.db.migrations.v10.json.SatoshiSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers + +enum class InboundLiquidityLeaseType { + @Deprecated("obsolete with the new on-the-fly channel funding that replaces lease -> purchase") + LEASE_V0 +} + +@Suppress("DEPRECATION_WARNING") +@Deprecated("obsolete with the new on-the-fly channel funding that replaces lease with purchase") +@Serializable +@SerialName("fr.acinq.phoenix.db.payments.liquidityads.LeaseV0") +data class LeaseV0( + val amount: Satoshi, + val miningFees: Satoshi, + val serviceFee: Satoshi, + val sellerSig: ByteVector64, + val witnessFundingScript: ByteVector, + val witnessLeaseDuration: Int, + val witnessLeaseEnd: Int, + val witnessMaxRelayFeeProportional: Int, + val witnessMaxRelayFeeBase: MilliSatoshi +) { + /** Maps a legacy lease data into the modern [LiquidityAds.Purchase] object using fake payment details data. */ + fun toLiquidityAdsPurchase(): LiquidityAds.Purchase = LiquidityAds.Purchase.Standard( + amount = amount, + fees = LiquidityAds.Fees(miningFee = miningFees, serviceFee = serviceFee), + paymentDetails = LiquidityAds.PaymentDetails.FromChannelBalance + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/PaymentDetailsData.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/PaymentDetailsData.kt new file mode 100644 index 00000000..b26d5069 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/PaymentDetailsData.kt @@ -0,0 +1,65 @@ +/* + * 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:UseSerializers( + ByteVector32Serializer::class, +) + +package fr.acinq.phoenix.db.migrations.v11.types.liquidityads + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.phoenix.db.migrations.v10.json.ByteVector32Serializer +import fr.acinq.lightning.wire.LiquidityAds +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers + + +@Serializable +sealed class PaymentDetailsData { + sealed class ChannelBalance : PaymentDetailsData() { + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.liquidityads.PaymentDetailsData.ChannelBalance.V0") + data object V0 : ChannelBalance() + } + + sealed class FutureHtlc : PaymentDetailsData() { + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.liquidityads.PaymentDetailsData.FutureHtlc.V0") + data class V0(val paymentHashes: List) : FutureHtlc() + } + + sealed class FutureHtlcWithPreimage : PaymentDetailsData() { + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.liquidityads.PaymentDetailsData.FutureHtlcWithPreimage.V0") + data class V0(val preimages: List) : FutureHtlcWithPreimage() + } + + sealed class ChannelBalanceForFutureHtlc : PaymentDetailsData() { + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.liquidityads.PaymentDetailsData.ChannelBalanceForFutureHtlc.V0") + data class V0(val paymentHashes: List) : ChannelBalanceForFutureHtlc() + } + + companion object { + fun PaymentDetailsData.asCanonical(): LiquidityAds.PaymentDetails = when (this) { + is ChannelBalance.V0 -> LiquidityAds.PaymentDetails.FromChannelBalance + is FutureHtlc.V0 -> LiquidityAds.PaymentDetails.FromFutureHtlc(this.paymentHashes) + is FutureHtlcWithPreimage.V0 -> LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage(this.preimages) + is ChannelBalanceForFutureHtlc.V0 -> LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(this.paymentHashes) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/PurchaseData.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/PurchaseData.kt new file mode 100644 index 00000000..1df019c0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/PurchaseData.kt @@ -0,0 +1,89 @@ +/* + * 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:UseSerializers( + SatoshiSerializer::class, + MilliSatoshiSerializer::class +) + +package fr.acinq.phoenix.db.migrations.v11.types.liquidityads + +import fr.acinq.bitcoin.Satoshi +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.phoenix.db.migrations.v11.types.liquidityads.PaymentDetailsData.Companion.asCanonical +import fr.acinq.phoenix.db.migrations.v10.json.SatoshiSerializer +import fr.acinq.phoenix.db.migrations.v10.json.MilliSatoshiSerializer +import fr.acinq.lightning.wire.LiquidityAds +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.json.Json + +@Serializable +sealed class PurchaseData { + sealed class Standard : PurchaseData() { + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.liquidityads.PurchaseData.Standard.V0") + data class V0( + val amount: Satoshi, + val miningFees: Satoshi, + val serviceFee: Satoshi, + val paymentDetails: PaymentDetailsData, + ) : Standard() + } + sealed class WithFeeCredit : PurchaseData() { + @Serializable + @SerialName("fr.acinq.phoenix.db.payments.liquidityads.PurchaseData.WithFeeCredit.V0") + data class V0( + val amount: Satoshi, + val miningFees: Satoshi, + val serviceFee: Satoshi, + val feeCreditUsed: MilliSatoshi, + val paymentDetails: PaymentDetailsData, + ) : WithFeeCredit() + } + + companion object { + private fun PurchaseData.asCanonical(): LiquidityAds.Purchase = when (this) { + is Standard.V0 -> LiquidityAds.Purchase.Standard( + amount = amount, + fees = LiquidityAds.Fees(miningFee = miningFees, serviceFee = serviceFee), + paymentDetails = paymentDetails.asCanonical() + ) + is WithFeeCredit.V0 -> LiquidityAds.Purchase.WithFeeCredit( + amount = amount, + fees = LiquidityAds.Fees(miningFee = miningFees, serviceFee = serviceFee), + feeCreditUsed = feeCreditUsed, + paymentDetails = paymentDetails.asCanonical() + ) + } + + /** + * Deserializes a json-encoded blob into a [LiquidityAds.Purchase] object. + * + * @param typeVersion only used for the legacy leased data, where the blob did not contain the type of the object. + */ + @Suppress("DEPRECATION") + fun decodeAsCanonical( + typeVersion: String, + blob: ByteArray, + ): LiquidityAds.Purchase = + when (typeVersion) { + InboundLiquidityLeaseType.LEASE_V0.name -> Json.decodeFromString(blob.decodeToString()).toLiquidityAdsPurchase() + else -> Json.decodeFromString(blob.decodeToString()).asCanonical() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/notifications/NotificationDataType.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/notifications/NotificationDataType.kt new file mode 100644 index 00000000..34fe5492 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/notifications/NotificationDataType.kt @@ -0,0 +1,116 @@ +/* + * 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. + */ + +@file:UseSerializers( + SatoshiSerializer::class, + MilliSatoshiSerializer::class, + ByteVector32Serializer::class, +) + +package fr.acinq.phoenix.db.notifications + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Satoshi +import fr.acinq.lightning.LiquidityEvents +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.phoenix.data.Notification +import fr.acinq.phoenix.db.migrations.v10.json.ByteVector32Serializer +import fr.acinq.phoenix.db.migrations.v10.json.MilliSatoshiSerializer +import fr.acinq.phoenix.db.migrations.v10.json.SatoshiSerializer +import io.ktor.utils.io.charsets.Charsets +import io.ktor.utils.io.core.toByteArray +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Serializable +internal sealed class NotificationData { + sealed class PaymentRejected : NotificationData() { + sealed class OverAbsoluteFee : PaymentRejected() { + @Serializable + data class V0( + @Serializable val amount: MilliSatoshi, + val source: LiquidityEvents.Source, + @Serializable val fee: MilliSatoshi, + @Serializable val maxAbsoluteFee: Satoshi, + ) : OverAbsoluteFee() + } + + sealed class OverRelativeFee : PaymentRejected() { + @Serializable + data class V0( + @Serializable val amount: MilliSatoshi, + val source: LiquidityEvents.Source, + @Serializable val fee: MilliSatoshi, + @Serializable val maxRelativeFeeBasisPoints: Int, + ) : OverRelativeFee() + } + + sealed class Disabled : PaymentRejected() { + @Serializable + data class V0(@Serializable val amount: MilliSatoshi, val source: LiquidityEvents.Source) : Disabled() + } + + sealed class MissingOffchainAmountTooLow : PaymentRejected() { + @Serializable + data class V0(@Serializable val amount: MilliSatoshi, val source: LiquidityEvents.Source) : MissingOffchainAmountTooLow() + } + + sealed class GenericError : PaymentRejected() { + @Serializable + data class V0(@Serializable val amount: MilliSatoshi, val source: LiquidityEvents.Source) : GenericError() + } + } + + sealed class WatchTowerOutcome: NotificationData() { + sealed class Unknown : WatchTowerOutcome() { + @Serializable + data object V0: Unknown() + } + sealed class Nominal : WatchTowerOutcome() { + @Serializable + data class V0(@Serializable val channelsWatchedCount: Int): Nominal() + } + sealed class RevokedFound : WatchTowerOutcome() { + @Serializable + data class V0(@Serializable val channels: Set<@Serializable ByteVector32>): RevokedFound() + } + } + + companion object { + + fun decode(blob: ByteArray): NotificationData? = try { + Json.decodeFromString(blob.decodeToString()) + } catch (e: Exception) { + // notifications are not critical data, can be ignored if malformed + null + } + + fun Notification.encodeAsDb(): ByteArray = Json.encodeToString(this.asDb()).toByteArray(Charsets.UTF_8) + + private fun Notification.asDb(): NotificationData = when (this) { + is Notification.OverAbsoluteFee -> PaymentRejected.OverAbsoluteFee.V0(amount, source, fee, maxAbsoluteFee) + is Notification.OverRelativeFee -> PaymentRejected.OverRelativeFee.V0(amount, source, fee, maxRelativeFeeBasisPoints) + is Notification.FeePolicyDisabled -> PaymentRejected.Disabled.V0(amount, source) + is Notification.MissingOffChainAmountTooLow -> PaymentRejected.MissingOffchainAmountTooLow.V0(amount, source) + is Notification.GenericError -> PaymentRejected.GenericError.V0(amount, source) + is fr.acinq.phoenix.data.WatchTowerOutcome.Nominal -> WatchTowerOutcome.Nominal.V0(channelsWatchedCount) + is fr.acinq.phoenix.data.WatchTowerOutcome.RevokedFound -> WatchTowerOutcome.RevokedFound.V0(channels) + is fr.acinq.phoenix.data.WatchTowerOutcome.Unknown -> WatchTowerOutcome.Unknown.V0 + } + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/notifications/NotificationsQueries.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/notifications/NotificationsQueries.kt new file mode 100644 index 00000000..447fd221 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/notifications/NotificationsQueries.kt @@ -0,0 +1,168 @@ +/* + * 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.db.notifications + +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList +import fr.acinq.lightning.LiquidityEvents +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.currentTimestampMillis +import fr.acinq.phoenix.data.Notification +import fr.acinq.phoenix.data.WatchTowerOutcome +import fr.acinq.phoenix.db.sqldelight.AppDatabase +import fr.acinq.phoenix.db.notifications.NotificationData.Companion.encodeAsDb +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +internal class NotificationsQueries(val database: AppDatabase) { + private val queries = database.notificationsQueries + + fun get(id: UUID): Notification? { + return queries.get(id.toString()).executeAsOneOrNull()?.let { row -> + mapToNotification(row.id, row.data_json, row.created_at, row.read_at) + } + } + + fun save(notification: Notification, nodeIdHash: String) { + queries.insert( + id = notification.id.toString(), + type_version = when (notification) { + is Notification.OverAbsoluteFee -> "PAYMENT_REJECTED_OVER_ABSOLUTE_FEE" + is Notification.OverRelativeFee -> "PAYMENT_REJECTED_OVER_RELATIVE_FEE" + is Notification.FeePolicyDisabled -> "PAYMENT_REJECTED_POLICY_DISABLED" + is Notification.MissingOffChainAmountTooLow -> "PAYMENT_REJECTED_OFFCHAIN_AMOUNT_TOO_LOW" + is Notification.GenericError -> "PAYMENT_REJECTED_GENERIC_ERROR" + is WatchTowerOutcome.Nominal -> "WATCH_TOWER_NOMINAL" + is WatchTowerOutcome.RevokedFound -> "WATCH_TOWER_REVOKED" + is WatchTowerOutcome.Unknown -> "WATCH_TOWER_UNKNOWN" + }, + data_json = notification.encodeAsDb(), + created_at = currentTimestampMillis(), + node_id_hash = nodeIdHash + ) + } + + /** Marks a list of notifications as read. */ + fun markAsRead(ids: Set) { + queries.markAsRead(read_at = currentTimestampMillis(), id = ids.map { it.toString() }) + } + + /** Marks all unread notifications as read. */ + fun markAllAsRead() { + queries.markAllAsRead(currentTimestampMillis()) + } + + fun initializeNodeIdHashColumn(nodeIdHash: String) { + queries.initializeNodeIdHashColumn(nodeIdHash) + } + + /** + * Returns a list of unread notifications, grouped by type (i.e. [Notification]), in order to avoid spamming the UI + * with duplicates. + * + * The set of UUIDs linked to a [Notification] can be used to execute an action on all the actual relevant data in the + * database (for example, to mark those notifications as read). + */ + fun listUnread(nodeIdHash: String): Flow, Notification>>> { + return queries.listUnread(nodeIdHash).asFlow().mapToList(Dispatchers.IO).map { + val notifs = it.mapNotNull { row -> + val ids = row.grouped_ids.split(";").map { UUID.fromString(it) }.toSet() + val notif = mapToNotification(row.id, row.data_json, row.max ?: 0, null) + if (notif != null) { + ids to notif + } else { + // invalid notifications are marked as read so that they are filtered by the SQL query next time + markAsRead(ids) + null + } + } + + val (pendingSwaps, others) = notifs.partition { + val notif = it.second + notif is Notification.PaymentRejected && notif.source == LiquidityEvents.Source.OnChainWallet + } + + // group swap notification by amount, and flatten the list + val pendingSwapsGroupedByAmount = pendingSwaps.mapNotNull { + val notif = it.second + if (notif is Notification.PaymentRejected) notif.amount to it else null + }.groupBy { it.first }.map { + val sameNotificationGroups = it.value.map { it.second } + val uuids = sameNotificationGroups.map { it.first }.flatten().toSet() + uuids to sameNotificationGroups.first().second + } + + (pendingSwapsGroupedByAmount + others).sortedByDescending { it.second.createdAt } + } + } + + companion object { + /** Map columns to a [Notification] object. If the [data_json] column is unreadable, return null. */ + internal fun mapToNotification( + id: String, + data_json: ByteArray, + created_at: Long, + read_at: Long?, + ): Notification? { + return when (val data = NotificationData.decode(data_json)) { + is NotificationData.PaymentRejected.OverAbsoluteFee.V0 -> Notification.OverAbsoluteFee( + id = UUID.fromString(id), + createdAt = created_at, + readAt = read_at, + amount = data.amount, + source = data.source, + fee = data.fee, + maxAbsoluteFee = data.maxAbsoluteFee + ) + is NotificationData.PaymentRejected.OverRelativeFee.V0 -> Notification.OverRelativeFee( + id = UUID.fromString(id), + createdAt = created_at, + readAt = read_at, + amount = data.amount, + source = data.source, + fee = data.fee, + maxRelativeFeeBasisPoints = data.maxRelativeFeeBasisPoints + ) + is NotificationData.PaymentRejected.Disabled.V0 -> Notification.FeePolicyDisabled( + id = UUID.fromString(id), + createdAt = created_at, + readAt = read_at, + amount = data.amount, + source = data.source, + ) + is NotificationData.PaymentRejected.MissingOffchainAmountTooLow.V0 -> Notification.MissingOffChainAmountTooLow( + id = UUID.fromString(id), + createdAt = created_at, + readAt = read_at, + amount = data.amount, + source = data.source, + ) + is NotificationData.PaymentRejected.GenericError.V0 -> Notification.GenericError( + id = UUID.fromString(id), + createdAt = created_at, + readAt = read_at, + amount = data.amount, + source = data.source, + ) + is NotificationData.WatchTowerOutcome -> null // ignored + null -> null + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/CloudKitInterface.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/CloudKitInterface.kt new file mode 100644 index 00000000..8ef32082 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/CloudKitInterface.kt @@ -0,0 +1,5 @@ +package fr.acinq.phoenix.db.payments + +/* Cross-platform placeholder for CloudKitDb. */ +interface CloudKitInterface { +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/MetadataTypes.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/MetadataTypes.kt new file mode 100644 index 00000000..436a4a8d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/MetadataTypes.kt @@ -0,0 +1,324 @@ +package fr.acinq.phoenix.db.payments + +import fr.acinq.bitcoin.ByteVector +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.phoenix.db.cloud.cborSerializer +import fr.acinq.phoenix.data.* +import fr.acinq.phoenix.data.lnurl.LnurlPay +import io.ktor.http.* +import kotlinx.serialization.* +import kotlinx.serialization.cbor.ByteString +import kotlinx.serialization.cbor.Cbor + +/** + * Represents the data stored in the `payments_metadata` table, within columns: + * - lnurl_base_type + * - lnurl_base_blob + */ +sealed class LnurlBase { + + enum class TypeVersion { + PAY_V0 + } + + @Serializable + data class Pay( + val lnurl: String, + val callback: String, + val minSendableMsat: Long, + val maxSendableMsat: Long, + val maxCommentLength: Long? + ): LnurlBase() { + constructor(intent: LnurlPay.Intent): this( + lnurl = intent.initialUrl.toString(), + callback = intent.callback.toString(), + minSendableMsat = intent.minSendable.msat, + maxSendableMsat = intent.maxSendable.msat, + maxCommentLength = intent.maxCommentLength + ) + + fun unwrap(metadata: LnurlPay.Intent.Metadata) = LnurlPay.Intent( + initialUrl = Url(this.lnurl), + callback = Url(this.callback), + minSendable = MilliSatoshi(this.minSendableMsat), + maxSendable = MilliSatoshi(this.maxSendableMsat), + maxCommentLength = this.maxCommentLength, + metadata = metadata + ) + } + + companion object { + @OptIn(ExperimentalSerializationApi::class) + fun deserialize(typeVersion: TypeVersion, blob: ByteArray): LnurlBase { + return when (typeVersion) { + TypeVersion.PAY_V0 -> { + cborSerializer().decodeFromByteArray(blob) + } + } + } + + @OptIn(ExperimentalSerializationApi::class) + fun serialize(pay: LnurlPay.Intent): Pair { + val wrapper = Pay(pay) + val blob = Cbor.encodeToByteArray(wrapper) + return Pair(TypeVersion.PAY_V0, blob) + } + } +} + +/** + * Represents the data stored in the `payments_metadata` table, within columns: + * - lnurl_metadata_type + * - lnurl_metadata_blob + */ +sealed class LnurlMetadata { + + enum class TypeVersion { + PAY_V0 + } + + @Serializable + data class PayMetadata( + val raw: String + ): LnurlMetadata() { + constructor(metadata: LnurlPay.Intent.Metadata): this( + raw = metadata.raw + ) + + fun unwrap(): LnurlPay.Intent.Metadata { + return LnurlPay.parseMetadata(this.raw) + } + } + + companion object { + @OptIn(ExperimentalSerializationApi::class) + fun deserialize(typeVersion: TypeVersion, blob: ByteArray): LnurlMetadata { + return when (typeVersion) { + TypeVersion.PAY_V0 -> { + cborSerializer().decodeFromByteArray(blob) + } + } + } + + @OptIn(ExperimentalSerializationApi::class) + fun serialize(metadata: LnurlPay.Intent.Metadata): Pair { + val wrapper = PayMetadata(metadata) + val blob = Cbor.encodeToByteArray(wrapper) + return Pair(TypeVersion.PAY_V0, blob) + } + } +} + +/** + * Represents the data stored in the `payments_metadata` table, within columns: + * - lnurl_successAction_type + * - lnurl_successAction_blob + */ +sealed class LnurlSuccessAction { + + enum class TypeVersion { + MESSAGE_V0, + URL_V0, + AES_V0 + } + + @Serializable + data class Message( + val message: String + ): LnurlSuccessAction() { + constructor(successAction: LnurlPay.Invoice.SuccessAction.Message): this( + message = successAction.message + ) + + fun unwrap() = LnurlPay.Invoice.SuccessAction.Message( + message = this.message + ) + } + + @Serializable + data class Url( + val description: String, + val url: String + ): LnurlSuccessAction() { + constructor(successAction: LnurlPay.Invoice.SuccessAction.Url): this( + description = successAction.description, + url = successAction.url.toString() + ) + + fun unwrap() = LnurlPay.Invoice.SuccessAction.Url( + description = this.description, + url = Url(this.url) + ) + } + + @Serializable + @OptIn(ExperimentalSerializationApi::class) + data class Aes( + val description: String, + @ByteString + val ciphertext: ByteArray, + @ByteString + val iv: ByteArray + ): LnurlSuccessAction() { + constructor(successAction: LnurlPay.Invoice.SuccessAction.Aes): this( + description = successAction.description, + ciphertext = successAction.ciphertext.toByteArray(), + iv = successAction.iv.toByteArray() + ) + + fun unwrap() = LnurlPay.Invoice.SuccessAction.Aes( + description = this.description, + ciphertext = ByteVector(this.ciphertext), + iv = ByteVector(this.iv) + ) + } + + companion object { + @OptIn(ExperimentalSerializationApi::class) + fun deserialize( + typeVersion: TypeVersion, + blob: ByteArray + ): LnurlPay.Invoice.SuccessAction { + return when (typeVersion) { + TypeVersion.MESSAGE_V0 -> { + cborSerializer().decodeFromByteArray(blob).unwrap() + } + TypeVersion.URL_V0 -> { + cborSerializer().decodeFromByteArray(blob).unwrap() + } + TypeVersion.AES_V0 -> { + cborSerializer().decodeFromByteArray(blob).unwrap() + } + } + } + + @OptIn(ExperimentalSerializationApi::class) + fun serialize(successAction: LnurlPay.Invoice.SuccessAction): Pair { + return when (successAction) { + is LnurlPay.Invoice.SuccessAction.Message -> { + val wrapper = Message(successAction) + val blob = Cbor.encodeToByteArray(wrapper) + Pair(TypeVersion.MESSAGE_V0, blob) + } + is LnurlPay.Invoice.SuccessAction.Url -> { + val wrapper = Url(successAction) + val blob = Cbor.encodeToByteArray(wrapper) + Pair(TypeVersion.URL_V0, blob) + } + is LnurlPay.Invoice.SuccessAction.Aes -> { + val wrapper = Aes(successAction) + val blob = Cbor.encodeToByteArray(wrapper) + Pair(TypeVersion.AES_V0, blob) + } + } + } + } +} + +data class WalletPaymentMetadataRow( + val lnurl_base: Pair? = null, + val lnurl_metadata: Pair? = null, + val lnurl_successAction: Pair? = null, + val lnurl_description: String? = null, + val original_fiat: Pair? = null, + val user_description: String? = null, + val user_notes: String? = null, + val lightning_address: String? = null, + val modified_at: Long? = null +) { + + fun deserialize(): WalletPaymentMetadata { + val base = lnurl_base?.let { (baseType, baseBlob) -> + when (val base = LnurlBase.deserialize(baseType, baseBlob)) { + is LnurlBase.Pay -> { + lnurl_metadata?.let { (metaType, metaBlob) -> + when (val metadata = LnurlMetadata.deserialize(metaType, metaBlob)) { + is LnurlMetadata.PayMetadata -> { + metadata.unwrap() + } + } + }?.let { metadata -> + base.unwrap(metadata) + } + } + } + } + + val successAction = lnurl_successAction?.let { + LnurlSuccessAction.deserialize(it.first, it.second) + } + + val lnurl = base?.let { + LnurlPayMetadata( + pay = it, + description = lnurl_description ?: it.metadata.plainText, + successAction = successAction + ) + } + + val originalFiat = original_fiat?.let { + FiatCurrency.valueOfOrNull(it.first)?.let { fiatCurrency -> + ExchangeRate.BitcoinPriceRate( + fiatCurrency = fiatCurrency, + price = it.second, + source = "originalFiat", + timestampMillis = 0 + ) + } + } + + return WalletPaymentMetadata( + lnurl = lnurl, + originalFiat = originalFiat, + userDescription = user_description, + userNotes = user_notes, + lightningAddress = lightning_address, + modifiedAt = modified_at + ) + } + + /** + * Returns true if all columns are null (excluding modified_at). + */ + fun isEmpty(): Boolean { + return lnurl_base == null + && lnurl_metadata == null + && lnurl_successAction == null + && lnurl_description == null + && original_fiat == null + && user_description == null + && user_notes == null + && lightning_address == null + } +} + +fun WalletPaymentMetadata.serialize(): WalletPaymentMetadataRow? { + + var lnurlBase: Pair? = null + var lnurlMetadata: Pair? = null + var lnurlSuccessAction: Pair? = null + var lnurlDescription: String? = null + + lnurl?.let { + lnurlBase = LnurlBase.serialize(it.pay) + lnurlMetadata = LnurlMetadata.serialize(it.pay.metadata) + lnurlSuccessAction = it.successAction?.let { successAction -> + LnurlSuccessAction.serialize(successAction) + } + lnurlDescription = it.pay.metadata.plainText + } + + val row = WalletPaymentMetadataRow( + lnurl_base = lnurlBase, + lnurl_metadata = lnurlMetadata, + lnurl_successAction = lnurlSuccessAction, + lnurl_description = lnurlDescription, + original_fiat = originalFiat?.let { Pair(it.fiatCurrency.name, it.price) }, + user_description = userDescription, + user_notes = userNotes, + lightning_address = lightningAddress, + modified_at = modifiedAt + ) + + return if (row.isEmpty()) null else row +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/PaymentsMetadataQueries.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/PaymentsMetadataQueries.kt new file mode 100644 index 00000000..b477a7b7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/PaymentsMetadataQueries.kt @@ -0,0 +1,154 @@ +package fr.acinq.phoenix.db.payments + +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.currentTimestampMillis +import fr.acinq.phoenix.data.* +import fr.acinq.phoenix.data.lnurl.LnurlPay +import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase +import fr.acinq.phoenix.db.didUpdateWalletPaymentMetadata +import io.ktor.http.* + +class PaymentsMetadataQueries(val database: PaymentsDatabase) { + + private val queries = database.paymentsMetadataQueries + + fun addMetadata( + id: UUID, + data: WalletPaymentMetadataRow + ) { + queries.addMetadata( + payment_id = id, + lnurl_base_type = data.lnurl_base?.first, + lnurl_base_blob = data.lnurl_base?.second, + lnurl_description = data.lnurl_description, + lnurl_metadata_type = data.lnurl_metadata?.first, + lnurl_metadata_blob = data.lnurl_metadata?.second, + lnurl_successAction_type = data.lnurl_successAction?.first, + lnurl_successAction_blob = data.lnurl_successAction?.second, + user_description = data.user_description, + user_notes = data.user_notes, + modified_at = data.modified_at, + original_fiat_type = data.original_fiat?.first, + original_fiat_rate = data.original_fiat?.second, + lightning_address = data.lightning_address + ) + didUpdateWalletPaymentMetadata(id, database) + } + + fun get(id: UUID): WalletPaymentMetadata? { + return queries.get(payment_id = id, mapper = ::mapAll).executeAsOneOrNull() + } + + fun updateUserInfo( + id: UUID, + userDescription: String?, + userNotes: String? + ) { + database.transaction { + val rowExists = queries.hasMetadata(payment_id = id).executeAsOne() > 0 + val modifiedAt = currentTimestampMillis() + if (rowExists) { + queries.updateUserInfo( + payment_id = id, + user_description = userDescription, + user_notes = userNotes, + modified_at = modifiedAt + ) + } else { + queries.addMetadata( + payment_id = id, + lnurl_base_type = null, + lnurl_base_blob = null, + lnurl_description = null, + lnurl_metadata_type = null, + lnurl_metadata_blob = null, + lnurl_successAction_type = null, + lnurl_successAction_blob = null, + user_description = userDescription, + user_notes = userNotes, + modified_at = modifiedAt, + original_fiat_type = null, + original_fiat_rate = null, + lightning_address = null + ) + } + didUpdateWalletPaymentMetadata(id, database) + } + } + + companion object { + + @Suppress("UNUSED_PARAMETER") + fun mapAll( + 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? + ): WalletPaymentMetadata { + val lnurlBase = + if (lnurl_base_type != null && lnurl_base_blob != null) { + Pair(lnurl_base_type, lnurl_base_blob) + } else null + + val lnurlMetadata = + if (lnurl_metadata_type != null && lnurl_metadata_blob != null) { + Pair(lnurl_metadata_type, lnurl_metadata_blob) + } else null + + val lnurlSuccesssAction = + if (lnurl_successAction_type != null && lnurl_successAction_blob != null) { + Pair(lnurl_successAction_type, lnurl_successAction_blob) + } else null + + val originalFiat = + if (original_fiat_type != null && original_fiat_rate != null) { + Pair(original_fiat_type, original_fiat_rate) + } else null + + return WalletPaymentMetadataRow( + lnurl_base = lnurlBase, + lnurl_metadata = lnurlMetadata, + lnurl_successAction = lnurlSuccesssAction, + lnurl_description = lnurl_description, + original_fiat = originalFiat, + user_description = user_description, + user_notes = user_notes, + lightning_address = lightning_address, + modified_at = modified_at + ).deserialize() + } + } +} + +fun LnurlPayMetadata.Companion.placeholder(description: String) = LnurlPayMetadata( + pay = LnurlPay.Intent( + initialUrl = Url("https://phoenix.acinq.co/"), + callback = Url("https://phoenix.acinq.co/"), + minSendable = MilliSatoshi(0), + maxSendable = MilliSatoshi(0), + metadata = LnurlPay.Intent.Metadata( + raw = "", + plainText = description, + longDesc = null, + imageJpg = null, + imagePng = null, + identifier = null, + email = null, + unknown = null + ), + maxCommentLength = null + ), + description = description, + successAction = null +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/SqliteIncomingPaymentsDb.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/SqliteIncomingPaymentsDb.kt new file mode 100644 index 00000000..821b5fe0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/SqliteIncomingPaymentsDb.kt @@ -0,0 +1,149 @@ +/* + * 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.payments + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.lightning.db.AutomaticLiquidityPurchasePayment +import fr.acinq.lightning.db.Bolt11IncomingPayment +import fr.acinq.lightning.db.IncomingPayment +import fr.acinq.lightning.db.IncomingPaymentsDb +import fr.acinq.lightning.db.LightningIncomingPayment +import fr.acinq.lightning.db.OnChainIncomingPayment +import fr.acinq.lightning.wire.LiquidityAds +import fr.acinq.phoenix.data.WalletPaymentMetadata +import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase +import fr.acinq.phoenix.db.didDeleteWalletPayment +import fr.acinq.phoenix.db.didSaveWalletPayment +import fr.acinq.phoenix.managers.PaymentMetadataQueue +import fr.acinq.phoenix.utils.extensions.deriveUUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class SqliteIncomingPaymentsDb( + private val database: PaymentsDatabase, + private val paymentMetadataQueue: PaymentMetadataQueue? +) : IncomingPaymentsDb { + + val metadataQueries = PaymentsMetadataQueries(database) + + override suspend fun addIncomingPayment(incomingPayment: IncomingPayment) { + val metadata = paymentMetadataQueue?.dequeue(incomingPayment.id).let { + paymentMetadataQueue?.enrichPaymentMetadata(it) + } + withContext(Dispatchers.Default) { + database.transaction { + _addIncomingPayment(incomingPayment, metadata) + } + } + } + + /** + * This method must be called inside a transaction block. + * Non suspending so the iOS app can call it when restoring data from the cloud. + * + * @param notify Set to false if `didSaveWalletPayment` should not be invoked + * (e.g. when downloading payments from the cloud) + */ + fun _addIncomingPayment(incomingPayment: IncomingPayment, metadata: WalletPaymentMetadata?, notify: Boolean = true) { + database.paymentsIncomingQueries.insert( + id = incomingPayment.id, + payment_hash = (incomingPayment as? LightningIncomingPayment)?.paymentHash, + tx_id = when (incomingPayment) { + is LightningIncomingPayment -> incomingPayment.liquidityPurchaseDetails?.txId + is OnChainIncomingPayment -> incomingPayment.txId + else -> null + }, + created_at = incomingPayment.createdAt, + received_at = incomingPayment.completedAt, + data_ = incomingPayment + ) + // if the payment is on-chain, save the tx id link to the db + when (incomingPayment) { + is OnChainIncomingPayment -> + database.onChainTransactionsQueries.insert( + payment_id = incomingPayment.id, + tx_id = incomingPayment.txId, + confirmed_at = incomingPayment.confirmedAt, + locked_at = incomingPayment.lockedAt + ) + + else -> {} + } + metadata?.serialize()?.let { row -> + metadataQueries.addMetadata(incomingPayment.id, row) + } + if (notify) { + didSaveWalletPayment(incomingPayment.id, database) + } + } + + override suspend fun getLightningIncomingPayment(paymentHash: ByteVector32): LightningIncomingPayment? = + withContext(Dispatchers.Default) { + database.paymentsIncomingQueries.getByPaymentHash(paymentHash).executeAsOneOrNull() as? LightningIncomingPayment + } + + override suspend fun receiveLightningPayment(paymentHash: ByteVector32, parts: List, liquidityPurchase: LiquidityAds.LiquidityTransactionDetails?) { + withContext(Dispatchers.Default) { + database.transaction { + when (val paymentInDb = database.paymentsIncomingQueries.getByPaymentHash(paymentHash).executeAsOneOrNull() as? LightningIncomingPayment) { + is LightningIncomingPayment -> { + val paymentInDb1 = paymentInDb.addReceivedParts(parts, liquidityPurchase) + database.paymentsIncomingQueries.update( + id = paymentInDb1.id, + data = paymentInDb1, + receivedAt = paymentInDb1.completedAt, + txId = paymentInDb1.liquidityPurchaseDetails?.txId + ) + liquidityPurchase?.let { + when (val autoLiquidityPayment = database.paymentsOutgoingQueries.listByTxId(liquidityPurchase.txId).executeAsOneOrNull()) { + is AutomaticLiquidityPurchasePayment -> { + val autoLiquidityPayment1 = autoLiquidityPayment.copy(incomingPaymentReceivedAt = paymentInDb1.completedAt) + database.paymentsOutgoingQueries.update( + id = autoLiquidityPayment1.id, + completed_at = autoLiquidityPayment1.completedAt, + succeeded_at = autoLiquidityPayment1.succeededAt, + data = autoLiquidityPayment1 + ) + } + else -> {} + } + } + didSaveWalletPayment(paymentInDb1.id, database) + } + null -> error("missing payment for payment_hash=$paymentHash") + } + } + } + } + + override suspend fun listLightningExpiredPayments(fromCreatedAt: Long, toCreatedAt: Long): List = + withContext(Dispatchers.Default) { + database.paymentsIncomingQueries.list(created_at_from = fromCreatedAt, created_at_to = toCreatedAt, offset = 0, limit = Long.MAX_VALUE) + .executeAsList() + .filterIsInstance() + .filter { it.parts.isEmpty() && it.paymentRequest.isExpired() } + } + + override suspend fun removeLightningIncomingPayment(paymentHash: ByteVector32): Boolean = + withContext(Dispatchers.Default) { + database.transactionWithResult { + database.paymentsIncomingQueries.deleteByPaymentHash(payment_hash = paymentHash) + didDeleteWalletPayment(paymentHash.deriveUUID(), database) + database.paymentsIncomingQueries.changes().executeAsOne() != 0L + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/SqliteOutgoingPaymentsDb.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/SqliteOutgoingPaymentsDb.kt new file mode 100644 index 00000000..0e516b57 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/SqliteOutgoingPaymentsDb.kt @@ -0,0 +1,176 @@ +/* + * 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.payments + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.lightning.db.OnChainOutgoingPayment +import fr.acinq.lightning.db.OutgoingPayment +import fr.acinq.lightning.db.OutgoingPaymentsDb +import fr.acinq.lightning.utils.UUID +import fr.acinq.phoenix.data.WalletPaymentMetadata +import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase +import fr.acinq.phoenix.db.didSaveWalletPayment +import fr.acinq.phoenix.managers.PaymentMetadataQueue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class SqliteOutgoingPaymentsDb( + private val database: PaymentsDatabase, + private val paymentMetadataQueue: PaymentMetadataQueue? +) : OutgoingPaymentsDb { + + val metadataQueries = PaymentsMetadataQueries(database) + + override suspend fun addLightningOutgoingPaymentParts(parentId: UUID, parts: List) { + withContext(Dispatchers.Default) { + database.transaction { + val payment = database.paymentsOutgoingQueries.get(parentId).executeAsOneOrNull() as LightningOutgoingPayment + val payment1 = payment.copy(parts = payment.parts + parts) + database.paymentsOutgoingQueries.update( + id = parentId, + data = payment1, + completed_at = null, + succeeded_at = null + ) + } + parts.forEach { part -> + database.paymentsOutgoingQueries.insertPartLink(part_id = part.id, parent_id = parentId) + } + didSaveWalletPayment(parentId, database) + } + } + + override suspend fun addOutgoingPayment(outgoingPayment: OutgoingPayment) { + val metadata = paymentMetadataQueue?.dequeue(outgoingPayment.id).let { + paymentMetadataQueue?.enrichPaymentMetadata(it) + } + withContext(Dispatchers.Default) { + database.transaction { + _addOutgoingPayment(outgoingPayment, metadata) + } + } + } + + /** + * This method must be called inside a transaction block. + * Non suspending so the iOS app can call it when restoring data from the cloud. + * + * @param notify Set to false if `didSaveWalletPayment` should not be invoked + * (e.g. when downloading payments from the cloud) + */ + fun _addOutgoingPayment(outgoingPayment: OutgoingPayment, metadata: WalletPaymentMetadata?, notify: Boolean = true) { + when (outgoingPayment) { + is LightningOutgoingPayment -> { + database.paymentsOutgoingQueries.insert( + id = outgoingPayment.id, + payment_hash = outgoingPayment.paymentHash, + tx_id = null, + created_at = outgoingPayment.createdAt, + completed_at = outgoingPayment.completedAt, + succeeded_at = outgoingPayment.succeededAt, + data_ = outgoingPayment + ) + outgoingPayment.parts.forEach { part -> + database.paymentsOutgoingQueries.insertPartLink(part_id = part.id, parent_id = outgoingPayment.id) + } + } + is OnChainOutgoingPayment -> { + database.paymentsOutgoingQueries.insert( + id = outgoingPayment.id, + payment_hash = null, + tx_id = outgoingPayment.txId, + created_at = outgoingPayment.createdAt, + completed_at = outgoingPayment.completedAt, + succeeded_at = outgoingPayment.succeededAt, + data_ = outgoingPayment + ) + database.onChainTransactionsQueries.insert( + payment_id = outgoingPayment.id, + tx_id = outgoingPayment.txId, + confirmed_at = outgoingPayment.confirmedAt, + locked_at = outgoingPayment.lockedAt + ) + } + } + metadata?.serialize()?.let { row -> + metadataQueries.addMetadata(outgoingPayment.id, row) + } + if (notify) { + didSaveWalletPayment(outgoingPayment.id, database) + } + } + + override suspend fun completeLightningOutgoingPayment(id: UUID, status: LightningOutgoingPayment.Status.Completed) { + withContext(Dispatchers.Default) { + database.transaction { + val payment = database.paymentsOutgoingQueries.get(id).executeAsOneOrNull() as LightningOutgoingPayment + val payment1 = payment.copy(status = status) + database.paymentsOutgoingQueries.update( + id = id, + data = payment1, + completed_at = payment1.completedAt, + succeeded_at = payment1.succeededAt, + ) + didSaveWalletPayment(id, database) + } + } + } + + override suspend fun completeLightningOutgoingPaymentPart(parentId: UUID, partId: UUID, status: LightningOutgoingPayment.Part.Status.Completed) { + withContext(Dispatchers.Default) { + database.transaction { + val payment = database.paymentsOutgoingQueries.get(parentId).executeAsOneOrNull() as LightningOutgoingPayment + val payment1 = payment.copy(parts = payment.parts.map { + when { + it.id == partId -> it.copy(status = status) + else -> it + } + }) + database.paymentsOutgoingQueries.update( + id = parentId, + data = payment1, + completed_at = null, // parts do not update parent timestamps + succeeded_at = null + ) + didSaveWalletPayment(parentId, database) + } + } + } + + override suspend fun getLightningOutgoingPayment(id: UUID): LightningOutgoingPayment? { + return withContext(Dispatchers.Default) { + database.paymentsOutgoingQueries.get(id).executeAsOneOrNull() as? LightningOutgoingPayment + } + } + + override suspend fun getLightningOutgoingPaymentFromPartId(partId: UUID): LightningOutgoingPayment? { + return withContext(Dispatchers.Default) { + database.transactionWithResult { + database.paymentsOutgoingQueries.getParentId(partId).executeAsOneOrNull()?.let { paymentId -> + database.paymentsOutgoingQueries.get(paymentId).executeAsOneOrNull() as? LightningOutgoingPayment + } + } + } + } + + override suspend fun listLightningOutgoingPayments(paymentHash: ByteVector32): List { + return withContext(Dispatchers.Default) { + database.paymentsOutgoingQueries.listByPaymentHash(paymentHash).executeAsList().filterIsInstance() + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/serialization/contacts/Serialization.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/serialization/contacts/Serialization.kt new file mode 100644 index 00000000..2d90875e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/serialization/contacts/Serialization.kt @@ -0,0 +1,19 @@ +package fr.acinq.phoenix.db.serialization.contacts + +import fr.acinq.phoenix.data.ContactInfo + +object Serialization { + + fun serialize(contact: ContactInfo): ByteArray { + return fr.acinq.phoenix.db.serialization.contacts.v1.Serialization.serialize(contact) + } + + fun deserialize(bin: ByteArray): Result { + return runCatching { + when (val version = bin.first().toInt()) { + 1 -> fr.acinq.phoenix.db.serialization.contacts.v1.Deserialization.deserialize(bin) + else -> error("unknown version $version") + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/serialization/contacts/v1/Deserialization.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/serialization/contacts/v1/Deserialization.kt new file mode 100644 index 00000000..d8e3a65c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/serialization/contacts/v1/Deserialization.kt @@ -0,0 +1,48 @@ +package fr.acinq.phoenix.db.serialization.contacts.v1 + +import fr.acinq.bitcoin.io.ByteArrayInput +import fr.acinq.bitcoin.io.Input +import fr.acinq.lightning.serialization.InputExtensions.readBoolean +import fr.acinq.lightning.serialization.InputExtensions.readByteVector32 +import fr.acinq.lightning.serialization.InputExtensions.readCollection +import fr.acinq.lightning.serialization.InputExtensions.readNullable +import fr.acinq.lightning.serialization.InputExtensions.readNumber +import fr.acinq.lightning.serialization.InputExtensions.readString +import fr.acinq.lightning.serialization.InputExtensions.readUuid +import fr.acinq.lightning.wire.OfferTypes +import fr.acinq.phoenix.data.ContactAddress +import fr.acinq.phoenix.data.ContactInfo +import fr.acinq.phoenix.data.ContactOffer + +object Deserialization { + + fun deserialize(bin: ByteArray): ContactInfo { + val input = ByteArrayInput(bin) + val version = input.read() + require(version == Serialization.VERSION_MAGIC) { "incorrect version $version, expected ${Serialization.VERSION_MAGIC}" } + return input.readContactInfo() + } + + private fun Input.readContactInfo() = ContactInfo( + id = readUuid(), + name = readString(), + photoUri = readNullable { readString() }, + useOfferKey = readBoolean(), + offers = readCollection { readContactOffer() }.toList(), + addresses = readCollection { readContactAddress() }.toList() + ) + + private fun Input.readContactOffer() = ContactOffer( + id = readByteVector32(), + offer = OfferTypes.Offer.decode(readString()).get(), + label = readNullable { readString() }, + createdAt = readNumber() + ) + + private fun Input.readContactAddress() = ContactAddress( + id = readByteVector32(), + address = readString(), + label = readNullable { readString() }, + createdAt = readNumber() + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/serialization/contacts/v1/Serialization.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/serialization/contacts/v1/Serialization.kt new file mode 100644 index 00000000..85cdca52 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/serialization/contacts/v1/Serialization.kt @@ -0,0 +1,49 @@ +package fr.acinq.phoenix.db.serialization.contacts.v1 + +import fr.acinq.bitcoin.io.ByteArrayOutput +import fr.acinq.bitcoin.io.Output +import fr.acinq.lightning.serialization.OutputExtensions.writeBoolean +import fr.acinq.lightning.serialization.OutputExtensions.writeByteVector32 +import fr.acinq.lightning.serialization.OutputExtensions.writeCollection +import fr.acinq.lightning.serialization.OutputExtensions.writeNullable +import fr.acinq.lightning.serialization.OutputExtensions.writeNumber +import fr.acinq.lightning.serialization.OutputExtensions.writeString +import fr.acinq.lightning.serialization.OutputExtensions.writeUuid +import fr.acinq.phoenix.data.ContactAddress +import fr.acinq.phoenix.data.ContactInfo +import fr.acinq.phoenix.data.ContactOffer + +object Serialization { + + const val VERSION_MAGIC = 1 + + fun serialize(o: ContactInfo): ByteArray { + val out = ByteArrayOutput() + out.write(VERSION_MAGIC) + out.writeContactInfo(o) + return out.toByteArray() + } + + private fun Output.writeContactInfo(o: ContactInfo) { + writeUuid(o.id) + writeString(o.name) + writeNullable(o.photoUri) { writeString(it) } + writeBoolean(o.useOfferKey) + writeCollection(o.offers) { writeContactOffer(it) } + writeCollection(o.addresses) { writeContactAddress(it) } + } + + private fun Output.writeContactOffer(o: ContactOffer) { + writeByteVector32(o.id) + writeString(o.offer.encode()) + writeNullable(o.label) { writeString(it) } + writeNumber(o.createdAt) + } + + private fun Output.writeContactAddress(o: ContactAddress) { + writeByteVector32(o.id) + writeString(o.address) + writeNullable(o.label) { writeString(it) } + writeNumber(o.createdAt) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/AppConfigurationManager.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/AppConfigurationManager.kt new file mode 100644 index 00000000..77381e6a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/AppConfigurationManager.kt @@ -0,0 +1,96 @@ +package fr.acinq.phoenix.managers + +import fr.acinq.bitcoin.Chain +import fr.acinq.lightning.blockchain.electrum.ElectrumWatcher +import fr.acinq.lightning.blockchain.electrum.HeaderSubscriptionResponse +import fr.acinq.lightning.io.Peer +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.data.ElectrumConfig +import fr.acinq.phoenix.data.PreferredFiatCurrencies +import fr.acinq.phoenix.data.StartupParams +import fr.acinq.phoenix.data.mainnetElectrumServers +import fr.acinq.phoenix.data.mainnetElectrumServersOnion +import fr.acinq.phoenix.data.platformElectrumRegtestConf +import fr.acinq.phoenix.data.testnetElectrumServers +import fr.acinq.phoenix.data.testnetElectrumServersOnion +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.launch + +class AppConfigurationManager( + private val chain: Chain, + private val electrumWatcher: ElectrumWatcher, +) : CoroutineScope by MainScope() { + + constructor(business: PhoenixBusiness) : this( + chain = business.chain, + electrumWatcher = business.electrumWatcher, + ) + + init { + watchElectrumMessages() + } + + /** + * Used by the [PeerManager] to know what parameters to use when starting + * up the [Peer] connection. If null, the [PeerManager] will wait before + * instantiating the [Peer]. + */ + private val _startupParams by lazy { MutableStateFlow(null) } + val startupParams: StateFlow by lazy { _startupParams } + internal fun setStartupParams(params: StartupParams) { + if (_startupParams.value == null) _startupParams.value = params + if (_isTorEnabled.value == null) _isTorEnabled.value = params.isTorEnabled + } + + /** + * Used by the [AppConnectionsDaemon] to know which server to connect to. + * If null, the daemon will wait for a config to be set. + */ + private val _electrumConfig by lazy { MutableStateFlow(null) } + val electrumConfig: StateFlow by lazy { _electrumConfig } + + /** + * Use this method to set a server to connect to. + * If null, will connect to a random server from the hard-coded list. + */ + fun updateElectrumConfig(config: ElectrumConfig.Custom?) { + _electrumConfig.value = config ?: ElectrumConfig.Random + } + + fun randomElectrumServer(isTorEnabled: Boolean) = when (chain) { + Chain.Mainnet -> if (isTorEnabled) mainnetElectrumServersOnion.random() else mainnetElectrumServers.random() + Chain.Testnet3 -> if (isTorEnabled) testnetElectrumServersOnion.random() else testnetElectrumServers.random() + Chain.Testnet4 -> TODO() + Chain.Signet -> TODO() + Chain.Regtest -> platformElectrumRegtestConf() + } + + /** The flow containing the electrum header responses messages. */ + private val _electrumMessages by lazy { MutableStateFlow(null) } + val electrumMessages: StateFlow = _electrumMessages + + private fun watchElectrumMessages() = launch { + electrumWatcher.client.notifications.filterIsInstance().collect { + _electrumMessages.value = it + } + } + + // Tor configuration + private val _isTorEnabled = MutableStateFlow(null) + val isTorEnabled get(): StateFlow = _isTorEnabled.asStateFlow() + fun updateTorUsage(enabled: Boolean): Unit { + _isTorEnabled.value = enabled + } + + private val _preferredFiatCurrencies = MutableStateFlow(null) + val preferredFiatCurrencies: StateFlow by lazy { _preferredFiatCurrencies } + + fun updatePreferredFiatCurrencies(current: PreferredFiatCurrencies) { + _preferredFiatCurrencies.value = current + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/AppConnectionsDaemon.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/AppConnectionsDaemon.kt new file mode 100644 index 00000000..e4bfd7a2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/AppConnectionsDaemon.kt @@ -0,0 +1,463 @@ +package fr.acinq.phoenix.managers + +import fr.acinq.lightning.blockchain.electrum.ElectrumClient +import fr.acinq.lightning.io.TcpSocket +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.utils.Connection +import fr.acinq.lightning.utils.ServerAddress +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.data.ElectrumConfig +import fr.acinq.lightning.logging.debug +import fr.acinq.lightning.logging.error +import fr.acinq.lightning.logging.info +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.managers.global.NetworkState +import fr.acinq.phoenix.utils.extensions.isOnion +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + + +class AppConnectionsDaemon( + loggerFactory: LoggerFactory, + private val configurationManager: AppConfigurationManager, + private val walletManager: WalletManager, + private val peerManager: PeerManager, + private val phoenixGlobal: PhoenixGlobal, + private val tcpSocketBuilder: suspend () -> TcpSocket.Builder, + private val electrumClient: ElectrumClient, +) : CoroutineScope by MainScope() { + + constructor(business: PhoenixBusiness) : this( + loggerFactory = business.loggerFactory, + configurationManager = business.appConfigurationManager, + walletManager = business.walletManager, + peerManager = business.peerManager, + phoenixGlobal = business.phoenixGlobal, + tcpSocketBuilder = business.tcpSocketBuilderFactory, + electrumClient = business.electrumClient + ) + + private val logger = loggerFactory.newLogger(this::class) + + private var peerConnectionJob: Job? = null + private var electrumConnectionJob: Job? = null + private var httpControlFlowEnabled: Boolean = false + + private data class TrafficControl( + val walletIsAvailable: Boolean = false, + val internetIsAvailable: Boolean = false, + val torIsEnabled: Boolean = false, + + /** + * Under normal circumstances, the connections are automatically managed based on whether + * or not the network connection is available. However, the app may need to influence + * this decision. + * + * For example, on iOS: + * - When the app goes into background mode, it wants to force a disconnect. + * - Unless a payment is in-flight, in which case it wants to stay connected until + * the payment completes. + * - And if the app is backgrounded, but the app receives a push notification, + * then it wants to re-connect and handle the incoming payment, + * and disconnect again afterwards. + * + * This complexity is handled by a simple voting mechanism. + * (Think: retainCount from manual memory-management systems) + * + * The rules are: + * - if disconnectCount > 0 => triggers disconnect & prevents future connection attempts + * - if disconnectCount <= 0 => allows connection based on network availability (as usual) + * + * Any part of the app that "votes" is expected to properly balance their calls. + * For example, on iOS: + * - When the app goes into the background, it increments the count (vote to disconnect) + * And when the app returns to the foreground, it decrements the count (undo vote) + * - When an in-flight payment is detected, it decrements the count (vote to remain connected). + * And when the payment completes, it increments the count (undo vote). + * - When a push notifications wakes the app, in decrements the count (vote to connect). + * And when it finishes processing, it increments the count (undo vote). + */ + val disconnectCount: Int = 0, + + /** If a configuration value changes, this value can be incremented to force a disconnection. Only used for Electrum. */ + val configVersion: Int = 0 + ) { + val canConnect get() = walletIsAvailable && internetIsAvailable && disconnectCount <= 0 + + fun incrementDisconnectCount(): TrafficControl { + val safeInc = disconnectCount.let { if (it == Int.MAX_VALUE) it else it + 1 } + return copy(disconnectCount = safeInc) + } + + fun decrementDisconnectCount(): TrafficControl { + val safeDec = disconnectCount.let { if (it == Int.MIN_VALUE) it else it - 1 } + return copy(disconnectCount = safeDec) + } + + override fun toString(): String { + return "can_connect=${canConnect.toString().uppercase()} (should_connect=${if (disconnectCount <= 0) "YES" else "NO ($disconnectCount)" } internet=${if (internetIsAvailable) "OK" else "NOK"} tor=${if (torIsEnabled) "YES" else "NO"})" + } + } + + private val torControlFlow = MutableStateFlow(TrafficControl()) + private val torControlChanges = Channel TrafficControl>() + + private val peerControlFlow = MutableStateFlow(TrafficControl()) + private val peerControlChanges = Channel TrafficControl>() + + private val electrumControlFlow = MutableStateFlow(TrafficControl()) + private val electrumControlChanges = Channel TrafficControl>() + + private val httpApiControlFlow = MutableStateFlow(TrafficControl()) + private val httpApiControlChanges = Channel TrafficControl>() + + private var _lastElectrumServerAddress = MutableStateFlow(null) + val lastElectrumServerAddress: StateFlow = _lastElectrumServerAddress + + init { + fun enableControlFlow( + label: String, + controlFlow: MutableStateFlow, + controlChanges: ReceiveChannel TrafficControl> + ) = launch { + controlChanges.consumeEach { change -> + val newState = controlFlow.value.change() + if (newState.walletIsAvailable && (label == "peer" || label == "electrum")) { + logger.debug { "$label $newState" } + } + controlFlow.value = newState + } + } + + enableControlFlow("tor", torControlFlow, torControlChanges) + enableControlFlow("peer", peerControlFlow, peerControlChanges) + enableControlFlow("electrum", electrumControlFlow, electrumControlChanges) + enableControlFlow("apis", httpApiControlFlow, httpApiControlChanges) + + // Wallet monitor + launch { + // Suspends until the wallet is initialized + walletManager.keyManager.filterNotNull().first() + logger.debug { "walletIsAvailable = true" } + torControlChanges.send { copy(walletIsAvailable = true) } + peerControlChanges.send { copy(walletIsAvailable = true) } + electrumControlChanges.send { copy(walletIsAvailable = true) } + httpApiControlChanges.send { copy(walletIsAvailable = true) } + } + + // Internet monitor + launch { + phoenixGlobal.networkMonitor.start() + phoenixGlobal.networkMonitor.networkState.collect { + val newValue = it == NetworkState.Available + logger.debug { "internetIsAvailable = $newValue" } + torControlChanges.send { copy(internetIsAvailable = newValue) } + peerControlChanges.send { copy(internetIsAvailable = newValue) } + electrumControlChanges.send { copy(internetIsAvailable = newValue) } + httpApiControlChanges.send { copy(internetIsAvailable = newValue) } + } + } + + // Tor enabled monitor + launch { + configurationManager.isTorEnabled.filterNotNull().collect { newValue -> + logger.debug { "torIsEnabled = $newValue" } + torControlChanges.send { copy(torIsEnabled = newValue) } + peerControlChanges.send { copy(torIsEnabled = newValue) } + electrumControlChanges.send { copy(torIsEnabled = newValue) } + httpApiControlChanges.send { copy(torIsEnabled = newValue) } + } + } + + // Peer + launch { + var configVersion = 0 + var torIsEnabled = false + peerControlFlow.collect { + val peer = peerManager.getPeer() + val forceDisconnect: Boolean = + if (configVersion != it.configVersion || torIsEnabled != it.torIsEnabled) { + configVersion = it.configVersion + torIsEnabled = it.torIsEnabled + true + } else { + false + } + if (forceDisconnect || !it.canConnect) { + peerConnectionJob?.let { job -> + logger.info { "disconnect and cancel peer connection loop" } + job.cancelAndJoin() + peer.disconnect() + peerConnectionJob = null + } + } + if (it.canConnect) { + if (peerConnectionJob == null) { + logger.debug { "starting peer connection loop" } + peerConnectionJob = connectionLoop( + name = "Peer", + statusStateFlow = peer.connectionState.stateIn(this) + ) { connectionAttempt -> + peer.socketBuilder = tcpSocketBuilder() + try { + val (connectTimeout, handshakeTimeout) = when { + connectionAttempt <= 1 -> 1.seconds to 2.seconds + connectionAttempt <= 3 -> 2.seconds to 4.seconds + connectionAttempt <= 6 -> 4.seconds to 7.seconds + connectionAttempt <= 10 -> 7.seconds to 10.seconds + else -> 10.seconds to 15.seconds + }.run { if (it.torIsEnabled) first.times(3) to second.times(3) else this } + logger.info { "calling Peer.connect with connect_timeout=$connectTimeout handshake_timeout=$handshakeTimeout" } + if (it.torIsEnabled && !peer.walletParams.trampolineNode.isOnion) { + logger.error { "PEER CONNECTION ABORTED: MUST USE AN ONION ADDRESS" } + } else { + val res = peer.connect(connectTimeout = connectTimeout, handshakeTimeout = handshakeTimeout) + logger.debug { "finished peer.connect ($res) " } + } + } catch (e: Exception) { + logger.error { "error when connecting to peer: ${e.message ?: e::class.simpleName}" } + } + } + } + } + } + } + + // Electrum + launch { + var configVersion = 0 + var torIsEnabled = false + electrumControlFlow.collect { + val forceDisconnect: Boolean = + if (configVersion != it.configVersion || torIsEnabled != it.torIsEnabled) { + configVersion = it.configVersion + torIsEnabled = it.torIsEnabled + true + } else { + false + } + if (forceDisconnect || !it.canConnect) { + electrumConnectionJob?.let { job -> + logger.info { "disconnect and cancel electrum connection loop" } + job.cancelAndJoin() + electrumClient.disconnect() + electrumConnectionJob = null + } + } + if (it.canConnect) { + if (electrumConnectionJob == null) { + logger.debug { "starting electrum connection loop" } + electrumConnectionJob = connectionLoop( + name = "Electrum", + statusStateFlow = electrumClient.connectionStatus.map { it.toConnectionState() }.stateIn(this) + ) { connectionAttempt -> + val electrumConfig = configurationManager.electrumConfig.value + val electrumServerAddress = when (electrumConfig) { + is ElectrumConfig.Custom -> electrumConfig.server + is ElectrumConfig.Random -> configurationManager.randomElectrumServer(it.torIsEnabled) + null -> null + } + if (electrumServerAddress == null) { + logger.debug { "ignoring electrum connection opportunity because no server is configured yet" } + } else { + try { + val handshakeTimeout = when { + it.torIsEnabled && connectionAttempt <= 6 -> 20.seconds + it.torIsEnabled -> 40.seconds + connectionAttempt <= 3 -> 4.seconds + connectionAttempt <= 6 -> 7.seconds + connectionAttempt <= 10 -> 15.seconds + else -> 20.seconds + } + logger.info { "calling ElectrumClient.connect to server=$electrumServerAddress with handshake_timeout=$handshakeTimeout" } + val requireOnionIfTorEnabled = electrumConfig is ElectrumConfig.Custom && electrumConfig.requireOnionIfTorEnabled + if (it.torIsEnabled && !electrumServerAddress.isOnion && requireOnionIfTorEnabled) { + logger.error { "ELECTRUM CONNECTION ABORTED: MUST USE AN ONION ADDRESS" } + } else { + electrumClient.connect(electrumServerAddress, tcpSocketBuilder(), timeout = handshakeTimeout) + } + } catch (e: Exception) { + logger.error { "error when connecting to electrum: ${e.message ?: e::class.simpleName}"} + } + } + _lastElectrumServerAddress.value = electrumServerAddress + } + } + } + } + } + + // HTTP APIs + launch { + httpApiControlFlow.collect { + when { + it.internetIsAvailable && it.disconnectCount <= 0 -> { + if (!httpControlFlowEnabled) { + httpControlFlowEnabled = true + phoenixGlobal.enableNetworkAccess() + } + } + else -> { + if (httpControlFlowEnabled) { + httpControlFlowEnabled = false + phoenixGlobal.disableNetworkAccess() + } + } + } + } + } + + // Listen to electrum configuration changes and reconnect when needed. + launch { + var previousElectrumConfig: ElectrumConfig? = null + configurationManager.electrumConfig.collect { newElectrumConfig -> + val changed = when (val oldElectrumConfig = previousElectrumConfig) { + null -> newElectrumConfig != null + else -> newElectrumConfig != oldElectrumConfig + } + if (changed) { + logger.info { "electrum config has changed: from=$previousElectrumConfig to $newElectrumConfig, reconnecting..." } + electrumControlChanges.send { copy(configVersion = configVersion + 1) } + } else { + logger.debug { "electrum config: no changes" } + } + previousElectrumConfig = newElectrumConfig + } + } + } + + data class ControlTarget(val flags: Int) { // <- bitmask + + companion object { + val Peer = ControlTarget(0b0001) + val Electrum = ControlTarget(0b0010) + val Http = ControlTarget(0b0100) + val Tor = ControlTarget(0b1000) + val All = ControlTarget(0b1111) + } + + /* The `+` operator is implemented, so it can be used like so: + * `val options = ControlTarget.Peer + ControlTarget.Electrum` + */ + operator fun plus(other: ControlTarget): ControlTarget { + return ControlTarget(this.flags or other.flags) + } + + fun contains(options: ControlTarget): Boolean { + return (this.flags and options.flags) != 0 + } + + val containsPeer get() = contains(Peer) + val containsElectrum get() = contains(Electrum) + val containsHttp get() = contains(Http) + val containsTor get() = contains(Tor) + } + + /** Vote to disconnect the target. */ + fun incrementDisconnectCount(target: ControlTarget = ControlTarget.All) { + launch { + if (target.containsPeer) { + peerControlChanges.send { incrementDisconnectCount() } + } + if (target.containsElectrum) { + electrumControlChanges.send { incrementDisconnectCount() } + } + if (target.containsHttp) { + httpApiControlChanges.send { incrementDisconnectCount() } + } + if (target.containsTor) { + torControlChanges.send { incrementDisconnectCount() } + } + } + } + + /** Vote to connect the target. */ + fun decrementDisconnectCount(target: ControlTarget = ControlTarget.All) { + launch { + if (target.containsPeer) { + peerControlChanges.send { decrementDisconnectCount() } + } + if (target.containsElectrum) { + electrumControlChanges.send { decrementDisconnectCount() } + } + if (target.containsHttp) { + httpApiControlChanges.send { decrementDisconnectCount() } + } + if (target.containsTor) { + torControlChanges.send { decrementDisconnectCount() } + } + } + } + + /** Vote to connect the target. */ + fun forceReconnect(target: ControlTarget = ControlTarget.All) { + launch { + if (target.containsPeer) { + peerControlChanges.send { copy(disconnectCount = -1, configVersion = this.configVersion + 1) } + } + if (target.containsElectrum) { + electrumControlChanges.send { copy(disconnectCount = -1, configVersion = this.configVersion + 1) } + } + if (target.containsHttp) { + httpApiControlChanges.send { copy(disconnectCount = -1) } + } + if (target.containsTor) { + torControlChanges.send { copy(disconnectCount = -1) } + } + } + } + + /** + * Attempts to connect to [name] everytime the connection changes to [Connection.CLOSED]. Repeated failed attempts + * are throttled with an exponential backoff. This pause cannot be cancelled, which can be an issue if the network + * conditions have just changed and we want to reconnect immediately. In this case this job should be cancelled + * altogether and restarted. + * + * @param connect the parameter is the current number of failed consecutive attempts. If this counter is large (i.e. + * we have connection issues), the internal connection method should use more lax parameters. Conversely, if + * we've just started the connection loop, the connection method should fail fast to provide a snappier UX. + */ + private fun connectionLoop( + name: String, + statusStateFlow: StateFlow, + connect: suspend (Int) -> Unit + ) = launch { + // tracks how many failed connection attempts have been made in a row + // when connection keeps failing, this loop is paused for a bit + var connectionCounter = 0 + statusStateFlow.collect { + logger.debug { "$name connection state is $it" } + if (it is Connection.CLOSED) { + val pause = connectionPause(connectionCounter) + logger.info { "next $name connection attempt #$connectionCounter in $pause" } + delay(pause) + connectionCounter++ + connect(connectionCounter) + } else if (it == Connection.ESTABLISHED) { + connectionCounter = 0 + } + } + } + + private fun connectionPause(attemptCount: Int): Duration { + return when { + attemptCount <= 0 -> 0.1.seconds + attemptCount == 1 -> 0.25.seconds + attemptCount == 2 -> 0.5.seconds + attemptCount == 3 -> 1.seconds + attemptCount == 4 -> 2.seconds + attemptCount == 5 -> 4.seconds + else -> 8.seconds + } + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/BalanceManager.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/BalanceManager.kt new file mode 100644 index 00000000..c0cabe79 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/BalanceManager.kt @@ -0,0 +1,102 @@ +package fr.acinq.phoenix.managers + +import fr.acinq.bitcoin.Satoshi +import fr.acinq.lightning.* +import fr.acinq.lightning.blockchain.electrum.SwapInManager +import fr.acinq.lightning.blockchain.electrum.WalletState +import fr.acinq.lightning.blockchain.electrum.balance +import fr.acinq.lightning.channel.states.ChannelState +import fr.acinq.lightning.io.Peer +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.utils.sum +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.utils.extensions.localBalance +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +class BalanceManager( + private val peerManager: PeerManager, +) : CoroutineScope by MainScope() { + + constructor(business: PhoenixBusiness): this( + peerManager = business.peerManager, + ) + + /** The aggregated channels' balance. This is the user's LN funds in the wallet. See [ChannelState.localBalance] */ + private val _balance = MutableStateFlow(null) + val balance: StateFlow = _balance + + /** The swap-in wallet. Reserved utxos are filtered out. */ + private val _swapInWallet = MutableStateFlow(null) + val swapInWallet: StateFlow = _swapInWallet + + /** The swap-in wallet balance. Reserved utxos are filtered out. */ + private val _swapInWalletBalance = MutableStateFlow(WalletBalance.empty()) + val swapInWalletBalance: StateFlow = _swapInWalletBalance + + init { + launch { monitorChannelsBalance(peerManager) } + launch { monitorSwapInBalance() } + } + + /** Monitors the channels' balance, first using the channels data from our database, then the live channels. */ + private suspend fun monitorChannelsBalance(peerManager: PeerManager) { + peerManager.channelsFlow.collect { channels -> + _balance.value = channels?.mapNotNull { it.value.state.localBalance() }?.sum() + } + } + + /** + * Constructs a user-friendly [WalletBalance] from [Peer.swapInWallet]. + * + * Utxos that are reserved for channels are excluded. This prevents a scenario where a channel is being created - and + * the Lightning balance is updated - but the utxos for this channel are not yet spent and are such still listed in + * the swap-in wallet flow. The UI would be incorrect for a while. + * + * See [SwapInManager.reservedWalletInputs] for details. + */ + private suspend fun monitorSwapInBalance() { + peerManager.swapInWallet.filterNotNull().collect { wallet -> + _swapInWallet.value = wallet + _swapInWalletBalance.value = WalletBalance( + deeplyConfirmed = wallet.deeplyConfirmed.balance, + weaklyConfirmed = wallet.weaklyConfirmed.balance, + weaklyConfirmedMinBlockNeeded = wallet.weaklyConfirmed.minOfOrNull { + wallet.confirmationsNeeded(it) + }, + unconfirmed = wallet.unconfirmed.balance, + locked = wallet.lockedUntilRefund.balance, + readyForRefund = wallet.readyForRefund.balance + ) + } + } +} + +/** + * Helper class representing the balance of the swap-in wallet. See [WalletState.WalletWithConfirmations]. + * + * @param deeplyConfirmed balance that is confirmed and that can be used for a swap. This amount would always be 0 if we were to + * systematically accept swaps. But swaps can fail, or be rejected (fee too high). + * @param weaklyConfirmed balance that is confirmed but not deep enough for a swap. + * @param weaklyConfirmedMinBlockNeeded minimum depth that the wallet's weakly confirmed utxos must reach. + * @param unconfirmed balance that is not confirmed yet. + * @param locked balance that cannot be swapped anymore, but cannot be spent yet either. + * @param readyForRefund balance that cannot be swapped anymore, but can be spent unilaterally. + */ +data class WalletBalance( + val deeplyConfirmed: Satoshi, + val weaklyConfirmed: Satoshi, + val weaklyConfirmedMinBlockNeeded: Int?, + val locked: Satoshi, + val readyForRefund: Satoshi, + val unconfirmed: Satoshi, +) { + val total get() = readyForRefund + locked + deeplyConfirmed + weaklyConfirmed + unconfirmed + + companion object { + fun empty() = WalletBalance(0.sat, 0.sat, null, 0.sat, 0.sat, 0.sat) + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/ConnectionsManager.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/ConnectionsManager.kt new file mode 100644 index 00000000..dbb7001e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/ConnectionsManager.kt @@ -0,0 +1,61 @@ +package fr.acinq.phoenix.managers + +import fr.acinq.lightning.blockchain.electrum.ElectrumClient +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.utils.Connection +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.managers.global.NetworkMonitor +import fr.acinq.phoenix.managers.global.NetworkState +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import fr.acinq.phoenix.utils.extensions.plus + +data class Connections( + val internet: Connection = Connection.CLOSED(reason = null), + val peer: Connection = Connection.CLOSED(reason = null), + val electrum: Connection = Connection.CLOSED(reason = null), +) { + val global : Connection get() = internet + peer + electrum +} + +@OptIn(ExperimentalCoroutinesApi::class) +class ConnectionsManager( + loggerFactory: LoggerFactory, + peerManager: PeerManager, + electrumClient: ElectrumClient, + networkMonitor: NetworkMonitor, +): CoroutineScope { + + constructor(business: PhoenixBusiness): this( + loggerFactory = business.loggerFactory, + peerManager = business.peerManager, + electrumClient = business.electrumClient, + networkMonitor = business.phoenixGlobal.networkMonitor + ) + + val log = loggerFactory.newLogger(this::class) + private val job = Job() + override val coroutineContext = MainScope().coroutineContext + job + + @OptIn(ExperimentalCoroutinesApi::class) + val connections = peerManager.peerState.filterNotNull().flatMapLatest { peer -> + combine( + peer.connectionState, + electrumClient.connectionStatus, + networkMonitor.networkState + ) { peerState, electrumStatus, internetState -> + Connections( + peer = peerState, + electrum = electrumStatus.toConnectionState(), + internet = when (internetState) { + NetworkState.Available -> Connection.ESTABLISHED + NetworkState.NotAvailable -> Connection.CLOSED(reason = null) + }, + ) + } + }.stateIn( + scope = this, + started = SharingStarted.Eagerly, + initialValue = Connections(), // default value is everything = closed + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/DataStoreManager.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/DataStoreManager.kt new file mode 100644 index 00000000..4bc52179 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/DataStoreManager.kt @@ -0,0 +1,143 @@ +/* + * 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.managers + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import fr.acinq.bitcoin.Chain +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.utils.PlatformContext +import fr.acinq.phoenix.utils.preferences.GlobalPrefs +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.io.IOException +import okio.FileSystem +import okio.Path +import okio.SYSTEM + +expect fun computePreferencePath( + platformContext: PlatformContext, + dataStoreFileName: String, +): Path + +class DataStoreManager( + private val ctx: PlatformContext, + private val chain: Chain, +) { + + constructor(business: PhoenixBusiness): this( + ctx = business.phoenixGlobal.ctx, + chain = business.chain, + ) + + fun loadGlobalPrefsForWallet(): GlobalPrefs { + val existingGlobalPrefs = _globalPrefsFlow.value + if (existingGlobalPrefs != null) return existingGlobalPrefs + + val newGlobalPrefs = PreferenceDataStoreFactory.createWithPath { + computePreferencePath( + platformContext = ctx, + dataStoreFileName = "globalprefs.preferences_pb", + ) + }.let { GlobalPrefs(it) } + + _globalPrefsFlow.value = newGlobalPrefs + + return newGlobalPrefs + } + + fun loadUserPrefsForWallet(walletId: WalletId): UserPrefs { + val existingUserPrefs = _userPrefsMapFlow.value[walletId] + if (existingUserPrefs != null) return existingUserPrefs + + val newUserPrefs = PreferenceDataStoreFactory.createWithPath { + computePreferencePath( + platformContext = ctx, + dataStoreFileName = "userprefs_${walletId.nodeIdHash}.preferences_pb", + ) + }.let { UserPrefs(it) } + + val newUserPrefsMap = _userPrefsMapFlow.value.toMutableMap() + newUserPrefsMap[walletId] = newUserPrefs + _userPrefsMapFlow.value = newUserPrefsMap + + return newUserPrefs + } + + fun loadInternalPrefsForWallet(walletId: WalletId): InternalPrefs { + val existingInternalPrefs = _internalPrefsMapFlow.value[walletId] + if (existingInternalPrefs != null) return existingInternalPrefs + + val newInternalPrefs = PreferenceDataStoreFactory.createWithPath { + computePreferencePath( + platformContext = ctx, + dataStoreFileName = "internalprefs_${walletId.nodeIdHash}.preferences_pb", + ) + }.let { InternalPrefs(it) } + + val newInternalPrefsMap = _internalPrefsMapFlow.value.toMutableMap() + newInternalPrefsMap[walletId] = newInternalPrefs + _internalPrefsMapFlow.value = newInternalPrefsMap + + return newInternalPrefs + } + + /** Deletes the preferences files for a given wallet id, and removes the preferences from the map flow. */ + fun deleteNodeUserPrefs(id: WalletId): Boolean { + val userPrefsPath = userPrefsPath(id) + val userPrefsFileDeleted = try { + FileSystem.SYSTEM.delete( + path = userPrefsPath, + mustExist = true + ) + true + } catch (e: IOException) { + false + } + + val internalPrefsPath = internalPrefsFile(id) + val internalPrefsFileDeleted = try { + FileSystem.SYSTEM.delete(internalPrefsPath) + true + } catch(e: IOException) { + false + } + + return userPrefsFileDeleted && internalPrefsFileDeleted + } + + private fun userPrefsPath(id: WalletId): Path { + return computePreferencePath( + platformContext = ctx, + dataStoreFileName = "userprefs_${id.nodeIdHash}.preferences_pb", + ) + } + private fun internalPrefsFile(id: WalletId): Path { + return computePreferencePath( + platformContext = ctx, + dataStoreFileName = "internalprefs_${id.nodeIdHash}.preferences_pb", + ) + } + + companion object { + // maps of: (wallet_id -> userPrefs) and (wallet_id -> internalPrefs) + private val _globalPrefsFlow = MutableStateFlow(null) + private val _userPrefsMapFlow = MutableStateFlow>(emptyMap()) + private val _internalPrefsMapFlow = MutableStateFlow>(emptyMap()) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/DatabaseManager.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/DatabaseManager.kt new file mode 100644 index 00000000..1a135ff8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/DatabaseManager.kt @@ -0,0 +1,129 @@ +package fr.acinq.phoenix.managers + +import fr.acinq.bitcoin.Chain +import fr.acinq.bitcoin.PublicKey +import fr.acinq.bitcoin.byteVector +import fr.acinq.lightning.db.Databases +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.logging.debug +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.db.SqliteAppDb +import fr.acinq.phoenix.db.SqliteChannelsDb +import fr.acinq.phoenix.db.SqlitePaymentsDb +import fr.acinq.phoenix.db.contacts.SqliteContactsDb +import fr.acinq.phoenix.db.createChannelsDbDriver +import fr.acinq.phoenix.db.createPaymentsDbDriver +import fr.acinq.phoenix.db.createSqliteChannelsDb +import fr.acinq.phoenix.db.createSqlitePaymentsDb +import fr.acinq.phoenix.db.makeCloudKitDb +import fr.acinq.phoenix.db.payments.CloudKitInterface +import fr.acinq.phoenix.managers.global.CurrencyManager +import fr.acinq.phoenix.utils.PlatformContext +import fr.acinq.phoenix.utils.extensions.phoenixName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch + +class DatabaseManager( + loggerFactory: LoggerFactory, + private val ctx: PlatformContext, + private val chain: Chain, + private val appDb: SqliteAppDb, + private val nodeParamsManager: NodeParamsManager, + appConfigurationManager: AppConfigurationManager, + currencyManager: CurrencyManager, +) : CoroutineScope by MainScope() { + + constructor(business: PhoenixBusiness): this( + loggerFactory = business.loggerFactory, + ctx = business.phoenixGlobal.ctx, + chain = business.chain, + appDb = business.phoenixGlobal.appDb, + nodeParamsManager = business.nodeParamsManager, + appConfigurationManager = business.appConfigurationManager, + currencyManager = business.phoenixGlobal.currencyManager, + ) + + private val log = loggerFactory.newLogger(this::class) + + private val _databases = MutableStateFlow(null) + val databases: StateFlow = _databases.asStateFlow() + + @OptIn(ExperimentalCoroutinesApi::class) + val contactsList = _databases.filterNotNull().flatMapLatest { it.payments.contacts.contactsList } + + @OptIn(ExperimentalCoroutinesApi::class) + val contactsDb = _databases.filterNotNull().mapLatest { it.payments.contacts } + + val paymentMetadataQueue = PaymentMetadataQueue(currencyManager = currencyManager, appConfigurationManager = appConfigurationManager) + + init { + launch { + nodeParamsManager.nodeParams.collect { nodeParams -> + if (nodeParams == null) return@collect + log.debug { "nodeParams available: building databases..." } + + val channelsDbDriver = createChannelsDbDriver(ctx, channelsDbName(chain, nodeParams.nodeId)) + val channelsDb = createSqliteChannelsDb(channelsDbDriver) + val paymentsDbDriver = createPaymentsDbDriver(ctx, paymentsDbName(chain, nodeParams.nodeId)) { log.e { "payments-db migration error: $it" } } + val paymentsDb = createSqlitePaymentsDb(paymentsDbDriver, paymentMetadataQueue, loggerFactory) + val cloudKitDb = makeCloudKitDb(appDb, paymentsDb) + log.debug { "databases object created" } + _databases.value = PhoenixDatabases( + channels = channelsDb, + payments = paymentsDb, + cloudKit = cloudKitDb, + ) + } + } + launch { + paymentsDb().contacts.migrateContactsIfNeeded(appDb) + } + } + + fun close() { + val db = databases.value + if (db != null) { + db.channels.close() + db.payments.close() + } + } + + suspend fun paymentsDb(): SqlitePaymentsDb { + val db = databases.filterNotNull().first() + return db.payments + } + + suspend fun contactsDb(): SqliteContactsDb { + return paymentsDb().contacts + } + + suspend fun cloudKitDb(): CloudKitInterface? { + val db = databases.filterNotNull().first() + return db.cloudKit + } + + companion object { + fun channelsDbName(chain: Chain, nodeId: PublicKey): String { + return "channels-${chain.phoenixName.lowercase()}-${nodeId.hash160().byteVector().toHex()}.sqlite" + } + + fun paymentsDbName(chain: Chain, nodeId: PublicKey): String { + return "payments-${chain.phoenixName.lowercase()}-${nodeId.hash160().byteVector().toHex()}.sqlite" + } + } +} + +data class PhoenixDatabases( + override val channels: SqliteChannelsDb, + override val payments: SqlitePaymentsDb, + val cloudKit: CloudKitInterface?, +): Databases diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/LnurlManager.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/LnurlManager.kt new file mode 100644 index 00000000..4401adbf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/LnurlManager.kt @@ -0,0 +1,192 @@ +/* + * 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.managers + +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.payment.PaymentRequest +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.data.lnurl.* +import fr.acinq.lightning.logging.error +import io.ktor.client.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject + + +class LnurlManager( + loggerFactory: LoggerFactory, + private val walletManager: WalletManager +) : CoroutineScope by MainScope() { + + // use special client for lnurl since we dont want ktor to break when receiving non-2xx response + private val httpClient: HttpClient by lazy { + HttpClient { + install(ContentNegotiation) { + json(json = Json { ignoreUnknownKeys = true }) + } + expectSuccess = false + } + } + + constructor(business: PhoenixBusiness) : this( + loggerFactory = business.loggerFactory, + walletManager = business.walletManager + ) + + private val log = loggerFactory.newLogger(this::class) + + /** Executes an HTTP GET request on the provided url and parses the JSON response into an [Lnurl] object. */ + fun executeLnurl(url: Url): Deferred = async { + val response: HttpResponse = try { + httpClient.get(url) + } catch (err: Throwable) { + throw LnurlError.RemoteFailure.CouldNotConnect(origin = url.host) + } + try { + val json = Lnurl.processLnurlResponse(response, log) + return@async Lnurl.parseLnurlJson(url, json) + } catch (e: Exception) { + val isLightningAddress = url.toString().contains("/.well-known/lnurlp/") + + when (e) { + is LnurlError.RemoteFailure.Detailed, + is LnurlError.RemoteFailure.Code -> { + when { + isLightningAddress -> throw LnurlError.RemoteFailure.LightningAddressError(url.host) + else -> throw e + } + } + is LnurlError.RemoteFailure.Unreadable -> { + val scheme = url.protocol.name.lowercase() + val isWebsite = scheme == "http" || scheme == "https" + when { + isWebsite -> throw LnurlError.RemoteFailure.IsWebsite(url.toString()) + else -> throw e + } + } + is LnurlError -> throw e + else -> throw LnurlError.RemoteFailure.Unreadable(url.host) + } + } + } + + /** + * Execute an HTTP GET request to obtain a [LnurlPay.Invoice] from a [LnurlPay.Intent]. May throw a + * [LnurlError.RemoteFailure] or a [LnurlError.Pay.Invoice] error. + * + * @param intent the description of the payment as provided by the service. + * @param amount the amount that the user is willing to pay to settle the [LnurlPay.Intent]. + * @param comment an optional string commenting the payment and sent to the service. + */ + fun requestPayInvoice( + intent: LnurlPay.Intent, + amount: MilliSatoshi, + comment: String? + ): Deferred = async { + + val builder = URLBuilder(intent.callback) + builder.appendParameter(name = "amount", value = amount.msat.toString()) + if (!comment.isNullOrEmpty()) { + builder.appendParameter(name = "comment", value = comment) + } + val callback = builder.build() + val origin = callback.host + + val response: HttpResponse = try { + httpClient.get(callback) + } catch (err: Throwable) { + throw LnurlError.RemoteFailure.CouldNotConnect(origin) + } + + val json = Lnurl.processLnurlResponse(response, log) + val invoice = LnurlPay.parseLnurlPayInvoice(intent, origin, json) + + // SPECS: LN WALLET verifies that the amount in the provided invoice equals the amount previously specified by user. + if (amount != invoice.invoice.amount) { + log.error { "rejecting invoice from $origin with amount_invoice=${invoice.invoice.amount} requested_amount=$amount" } + throw LnurlError.Pay.Invoice.InvalidAmount(origin) + } + + return@async invoice + } + + /** + * Send an invoice to a lnurl service following a [LnurlWithdraw] request. + * Throw [LnurlError.RemoteFailure]. + */ + fun sendWithdrawInvoice( + lnurlWithdraw: LnurlWithdraw, + paymentRequest: PaymentRequest + ): Deferred = async { + + val builder = URLBuilder(lnurlWithdraw.callback) + builder.appendParameter(name = "k1", value = lnurlWithdraw.k1) + builder.appendParameter(name = "pr", value = paymentRequest.write()) + val callback = builder.build() + val origin = callback.host + + val response: HttpResponse = try { + httpClient.get(callback) + } catch (err: Throwable) { + throw LnurlError.RemoteFailure.CouldNotConnect(origin) + } + + // SPECS: even if the response is an error, the invoice may still be paid by the service + // we still parse the response to see what's up. + Lnurl.processLnurlResponse(response, log) + } + + suspend fun signAndSendAuthRequest( + auth: LnurlAuth, + scheme: LnurlAuth.Scheme, + ) { + val key = LnurlAuth.getAuthLinkingKey( + localKeyManager = walletManager.keyManager.filterNotNull().first(), + serviceUrl = auth.initialUrl, + scheme = scheme + ) + val (pubkey, signedK1) = LnurlAuth.signChallenge(auth.k1, key) + + val builder = URLBuilder(auth.initialUrl) + builder.appendParameter(name = "sig", value = signedK1.toHex()) + builder.appendParameter(name = "key", value = pubkey.toString()) + val url = builder.build() + + val response: HttpResponse = try { + httpClient.get(url) + } catch (t: Throwable) { + throw LnurlError.RemoteFailure.CouldNotConnect(origin = url.host) + } + + Lnurl.processLnurlResponse(response, log) // throws on any/all non-success + } +} + +private fun URLBuilder.appendParameter(name: String, value: String) { + this.parameters.append(name = name, value = value) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/NodeParamsManager.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/NodeParamsManager.kt new file mode 100644 index 00000000..c85f779c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/NodeParamsManager.kt @@ -0,0 +1,110 @@ +/* + * 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.managers + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Chain +import fr.acinq.bitcoin.PublicKey +import fr.acinq.bitcoin.Satoshi +import fr.acinq.lightning.NodeParams +import fr.acinq.lightning.NodeUri +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.payment.LiquidityPolicy +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.wire.LiquidityAds +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.lightning.logging.info +import fr.acinq.phoenix.data.OfferData +import fr.acinq.phoenixd.conf.LSP +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.hours + + +class NodeParamsManager( + loggerFactory: LoggerFactory, + chain: Chain, + walletManager: WalletManager, + appConfigurationManager: AppConfigurationManager, +) : CoroutineScope by MainScope() { + + constructor(business: PhoenixBusiness): this( + loggerFactory = business.loggerFactory, + chain = business.chain, + walletManager = business.walletManager, + appConfigurationManager = business.appConfigurationManager, + ) + + private val log = loggerFactory.newLogger(this::class) + + private val _nodeParams = MutableStateFlow(null) + val nodeParams: StateFlow = _nodeParams + + init { + launch { + combine( + walletManager.keyManager.filterNotNull(), + appConfigurationManager.startupParams.filterNotNull(), + ) { keyManager, startupParams -> + NodeParams( + chain = chain, + loggerFactory = loggerFactory, + keyManager = keyManager, + ).copy( + zeroConfPeers = setOf(trampolineNodeId), + liquidityPolicy = MutableStateFlow(startupParams.liquidityPolicy), + ) + }.collect { + log.info { "hello!" } + log.info { "nodeid=${it.nodeId}" } +// log.info { "commit=${BuildVersions.PHOENIX_COMMIT}" } +// log.info { "lightning-kmp version=${BuildVersions.LIGHTNING_KMP_VERSION}" } + _nodeParams.value = it + } + } + } + + /** See [NodeParams.defaultOffer]. Returns an [OfferData] object. */ + suspend fun defaultOffer(): OfferData { + return nodeParams.filterNotNull().first().defaultOffer(trampolineNodeId).let { + OfferData(it.offer, it.privateKey) // Confirm this maps correctly.. + } + } + + companion object { + val chain = Chain.Mainnet + + val lsp = LSP.from(chain) + + val trampolineNodeId = PublicKey.fromHex(lsp.walletParams.trampolineNode.id.toHex()) + val trampolineNodeUri = lsp.walletParams.trampolineNode + val trampolineNodeOnionUri = NodeUri(id = trampolineNodeId, "iq7zhmhck54vcax2vlrdcavq2m32wao7ekh6jyeglmnuuvv3js57r4id.onion", 9735) + val remoteSwapInXpub = lsp.swapInXpub + val defaultLiquidityPolicy = LiquidityPolicy.Auto( + inboundLiquidityTarget = null, // auto inbound liquidity is disabled (it must be purchased manually) + maxAbsoluteFee = 5_000.sat, + maxRelativeFeeBasisPoints = 50_00 /* 50% */, + skipAbsoluteFeeCheck = false, + maxAllowedFeeCredit = 0.msat, // no fee credit + ) + + val payToOpenFeeBase = 100 + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/NotificationsManager.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/NotificationsManager.kt new file mode 100644 index 00000000..96380540 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/NotificationsManager.kt @@ -0,0 +1,144 @@ +/* + * 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.managers + +import fr.acinq.lightning.LiquidityEvents +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.currentTimestampMillis +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.data.Notification +import fr.acinq.phoenix.data.WatchTowerOutcome +import fr.acinq.phoenix.db.SqliteAppDb +import fr.acinq.lightning.logging.debug +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +class NotificationsManager( + private val loggerFactory: LoggerFactory, + private val appDb: SqliteAppDb, + private val walletManager: WalletManager +) : CoroutineScope by MainScope() { + + constructor(business: PhoenixBusiness) : this( + loggerFactory = business.loggerFactory, + appDb = business.phoenixGlobal.appDb, + walletManager = business.walletManager + ) + + private val log = loggerFactory.newLogger(this::class) + + private val _notifications = MutableStateFlow, Notification>>>(emptyList()) + /** + * List of notifications grouped by notification type. The > can be used + * to execute an action on all the underlying notification data in the database. + */ + val notifications = _notifications.asStateFlow() + + init { + launch { monitorNotifications() } + } + + private suspend fun getNodeIdHash(): String { + return walletManager.keyManager.filterNotNull().first().nodeIdHash() + } + + private suspend fun monitorNotifications() { + val nodeIdHash = getNodeIdHash() + appDb.initializeNodeIdHashColumn(nodeIdHash) + appDb.listUnreadNotification(nodeIdHash).collect { + _notifications.value = it + } + } + + suspend fun getNotificationDetails(id: UUID): Notification? { + return appDb.getNotification(id) + } + + suspend fun saveWatchTowerOutcome(outcome: WatchTowerOutcome) { + log.debug { "persisting watch-tower-outcome=$outcome" } + val nodeIdHash = getNodeIdHash() + appDb.saveNotification(outcome, nodeIdHash) + } + + internal suspend fun saveLiquidityEventNotification(event: LiquidityEvents) { + log.debug { "persisting liquidity_event=$event" } + val nodeIdHash = getNodeIdHash() + when (event) { + is LiquidityEvents.Rejected -> { + val notification = when (val reason = event.reason) { + is LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee -> Notification.OverAbsoluteFee( + id = UUID.randomUUID(), + createdAt = currentTimestampMillis(), + readAt = null, + amount = event.amount, + fee = event.fee, + source = event.source, + maxAbsoluteFee = reason.maxAbsoluteFee + ) + is LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee -> Notification.OverRelativeFee( + id = UUID.randomUUID(), + createdAt = currentTimestampMillis(), + readAt = null, + amount = event.amount, + fee = event.fee, + source = event.source, + maxRelativeFeeBasisPoints = reason.maxRelativeFeeBasisPoints + ) + is LiquidityEvents.Rejected.Reason.PolicySetToDisabled -> Notification.FeePolicyDisabled( + id = UUID.randomUUID(), + createdAt = currentTimestampMillis(), + readAt = null, + amount = event.amount, + source = event.source, + ) + is LiquidityEvents.Rejected.Reason.MissingOffChainAmountTooLow -> Notification.MissingOffChainAmountTooLow( + id = UUID.randomUUID(), + createdAt = currentTimestampMillis(), + readAt = null, + amount = event.amount, + source = event.source, + ) + is LiquidityEvents.Rejected.Reason.ChannelFundingInProgress, + is LiquidityEvents.Rejected.Reason.NoMatchingFundingRate, + is LiquidityEvents.Rejected.Reason.TooManyParts -> Notification.GenericError( + id = UUID.randomUUID(), + createdAt = currentTimestampMillis(), + readAt = null, + amount = event.amount, + source = event.source, + ) + } + appDb.saveNotification(notification, nodeIdHash) + } + else -> {} + } + } + + fun dismissNotifications(ids: Set) { + launch { appDb.dismissNotifications(ids) } + } + + fun dismissAllNotifications() { + launch { appDb.dismissAllNotifications() } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PaymentMetadataQueue.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PaymentMetadataQueue.kt new file mode 100644 index 00000000..0a6da06e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PaymentMetadataQueue.kt @@ -0,0 +1,64 @@ +/* + * 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.managers + +import fr.acinq.lightning.utils.UUID +import fr.acinq.phoenix.data.WalletPaymentMetadata +import fr.acinq.phoenix.managers.global.CurrencyManager +import kotlinx.coroutines.flow.MutableStateFlow + + +/** + * The lightning-kmp layer triggers the addition of a payment to the database. + * But sometimes there is associated metadata that we want to include, + * and we would like to write it to the database within the same transaction. + * So we have a system to enqueue/dequeue associated metadata. + */ +class PaymentMetadataQueue( + private val appConfigurationManager: AppConfigurationManager, + private val currencyManager: CurrencyManager, +) { + + private var metadataQueue = MutableStateFlow(mapOf()) + + /** Adds a payment metadata to the queue. */ + fun enqueue(row: WalletPaymentMetadata, id: UUID) { + val oldMap = metadataQueue.value + val newMap = oldMap + (id to row) + metadataQueue.value = newMap + } + + /** Pops a payment metadata from the queue if it exists. */ + internal fun dequeue(id: UUID): WalletPaymentMetadata? { + val oldMap = metadataQueue.value + val newMap = oldMap - id + metadataQueue.value = newMap + return oldMap[id] + } + + /** + * Ensures the given payment metadata contains a fiat exchange rate. It it does not have a fiat rate already, + * fetch the current primary rate provided by the currency manager. + */ + internal fun enrichPaymentMetadata(metadata: WalletPaymentMetadata?): WalletPaymentMetadata { + val metadataOrDefault = metadata ?: WalletPaymentMetadata() + return if (metadataOrDefault.originalFiat == null) { + val currentFiatRate = appConfigurationManager.preferredFiatCurrencies.value?.let { currencyManager.calculateOriginalFiat(it.primary) } + metadataOrDefault.copy(originalFiat = currentFiatRate) + } else metadataOrDefault + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PaymentsManager.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PaymentsManager.kt new file mode 100644 index 00000000..d42d7116 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PaymentsManager.kt @@ -0,0 +1,121 @@ +package fr.acinq.phoenix.managers + +import fr.acinq.bitcoin.TxId +import fr.acinq.lightning.PaymentEvents +import fr.acinq.lightning.blockchain.electrum.ElectrumClient +import fr.acinq.lightning.db.IncomingPayment +import fr.acinq.lightning.db.LightningIncomingPayment +import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.lightning.db.WalletPayment +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.logging.debug +import fr.acinq.lightning.logging.info +import fr.acinq.lightning.utils.* +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.data.* +import fr.acinq.phoenix.db.SqlitePaymentsDb +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + + +class PaymentsManager( + private val loggerFactory: LoggerFactory, + private val configurationManager: AppConfigurationManager, + private val databaseManager: DatabaseManager, + private val electrumClient: ElectrumClient, + private val nodeParamsManager: NodeParamsManager, +) : CoroutineScope by MainScope() { + + constructor(business: PhoenixBusiness) : this( + loggerFactory = business.loggerFactory, + configurationManager = business.appConfigurationManager, + databaseManager = business.databaseManager, + electrumClient = business.electrumClient, + nodeParamsManager = business.nodeParamsManager, + ) + + private val log = loggerFactory.newLogger(this::class) + + /** Contains the most recently completed payment (only Lightning incoming/outgoing). */ + private val _lastCompletedPayment = MutableStateFlow(null) + val lastCompletedPayment: StateFlow = _lastCompletedPayment + + fun makePageFetcher(): PaymentsPageFetcher { + return PaymentsPageFetcher(loggerFactory, databaseManager) + } + + init { + launch { monitorLastCompletedPayment() } + launch { monitorUnconfirmedTransactions() } + } + + private suspend fun monitorLastCompletedPayment() { + val nodeParams = nodeParamsManager.nodeParams.filterNotNull().first() + nodeParams.nodeEvents.filterIsInstance().collect { + when (it) { + is PaymentEvents.PaymentReceived -> if (it.payment is LightningIncomingPayment) _lastCompletedPayment.value = it.payment + is PaymentEvents.PaymentSent -> if (it.payment is LightningOutgoingPayment) _lastCompletedPayment.value = it.payment + } + } + } + + /** Watches transactions that are unconfirmed, checks their confirmation status at each block, and updates relevant payments. */ + private suspend fun monitorUnconfirmedTransactions() { + val paymentsDb = databaseManager.paymentsDb() + // We need to recheck anytime either: + // - the list of unconfirmed txs changes + // - a new block is mined + combine( + paymentsDb.listUnconfirmedTransactions(), + configurationManager.electrumMessages + ) { unconfirmedTxs, header -> + unconfirmedTxs to header?.blockHeight + }.collect { (unconfirmedTxs, blockHeight) -> + if (blockHeight != null) { + log.debug { "checking confirmation status of ${unconfirmedTxs.size} txs at block=$blockHeight" } + unconfirmedTxs.forEach { txId -> + val conf = electrumClient.getConfirmations(txId) + log.info { "found confirmations=$conf for tx=$txId" } + if (conf != null && conf > 0) { + paymentsDb.setConfirmed(txId) + } + } + } + } + } + + suspend fun updateMetadata(id: UUID, userDescription: String?) { + databaseManager.paymentsDb().updateUserInfo(id = id, userDescription = userDescription, userNotes = null) + } + + /** + * Returns payment(s) related to a transaction id. Useful to link a commitment change in a channel to the + * payment(s) that triggered that change. + */ + suspend fun listPaymentsForTxId(txId: TxId): List { + return databaseManager.paymentsDb().listPaymentsForTxId(txId) + } + + /** Returns the first incoming payment related to a transaction id. Useful to find the incoming payment that triggered a liquidity purchase. */ + suspend fun getIncomingPaymentForTxId(txId: TxId): WalletPayment? { + return listPaymentsForTxId(txId).filterIsInstance().firstOrNull() + } + + suspend fun getPayment(id: UUID): WalletPaymentInfo? { + val db = databaseManager.paymentsDb() + return db.getPayment(id)?.let { + WalletPaymentInfo( + payment = it.first, + metadata = it.second ?: WalletPaymentMetadata(), + contact = db.contacts.contactForPayment(it.first, it.second) + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PaymentsPageFetcher.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PaymentsPageFetcher.kt new file mode 100644 index 00000000..42aa0522 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PaymentsPageFetcher.kt @@ -0,0 +1,228 @@ +package fr.acinq.phoenix.managers + +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.logging.debug +import fr.acinq.phoenix.data.WalletPaymentInfo +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlin.time.Clock +import kotlin.time.Instant +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime + +data class PaymentsPage( + /** The offset value you passed to the `subscribeToX()` function. */ + val offset: Int, + /** The count value you passed to the `subscribeToX()` function. */ + val count: Int, + /** + * The rows fetched from the database. + * If there are fewer items in the database than requested, + * then PaymentsPage.rows.count will be less than PaymentsPage.count. + */ + val rows: List +) { + constructor(): this(0, 0, emptyList()) +} + +@OptIn(ExperimentalTime::class) +class PaymentsPageFetcher( + loggerFactory: LoggerFactory, + private val databaseManager: DatabaseManager +): CoroutineScope by MainScope() { + + private val log = loggerFactory.newLogger(this::class) + + private var offset: Int = 0 + private var count: Int = 0 + private var seconds: Int = Int.MIN_VALUE + private var subscriptionIdx: Int = 0 + private var job: Job? = null + private var refreshJob: Job? = null + + /** + * A flow containing a page of payment rows. + * This is controlled by the `subscribe()` function. + * You use that function to control initialize the flow, and to modify it. + * + * Note: + * iOS (with SwiftUI & LazyVStack) has some issues supporting a non-zero offset. + * So on iOS, we're currently only incrementing the count. + */ + private val _paymentsPage = MutableStateFlow(PaymentsPage()) + val paymentsPage: StateFlow = _paymentsPage + + fun subscribeToAll(offset: Int, count: Int) { + log.debug { "subscribeToAll(offset=$offset, count=$count)" } + + if (this.offset == offset && this.count == count && this.seconds == Int.MIN_VALUE) { + // No changes + log.debug { "ignoring: no changes" } + return + } + this.job?.let { + log.debug { "cancelling previous job" } + it.cancel() + this.job = null + } + + // There could be a significant delay between requesting the list + // and receiving the list. So the offset/count are used to track + // the current request, even if it hasn't completed yet. + + this.offset = offset.coerceAtLeast(minimumValue = 0) + this.count = count.coerceAtLeast(minimumValue = 1) + this.seconds = Int.MIN_VALUE + this.subscriptionIdx += 1 + + val offsetSnapshot = offset + val countSnapshot = count + val subscriptionIdxSnapshot = subscriptionIdx + this.job = launch { + val db = databaseManager.paymentsDb() + db.listPaymentsAsFlow( + count = countSnapshot.toLong(), + skip = offsetSnapshot.toLong() + ).collect { rows -> + if (subscriptionIdxSnapshot == subscriptionIdx) { + _paymentsPage.value = PaymentsPage( + offset = offsetSnapshot, + count = countSnapshot, + rows = rows + ) + } + } + } + } + + fun subscribeToInFlight(offset: Int, count: Int) { + log.debug { "subscribeToInFlight(offset=$offset, count=$count)" } + + if (this.offset == offset && this.count == count && this.seconds == 0) { + // No changes + log.debug { "ignoring: no changes" } + return + } + this.job?.let { + log.debug { "cancelling previous job" } + it.cancel() + this.job = null + } + + this.offset = offset.coerceAtLeast(minimumValue = 0) + this.count = count.coerceAtLeast(minimumValue = 1) + this.seconds = 0 + this.subscriptionIdx += 1 + + val offsetSnapshot = offset + val countSnapshot = count + val subscriptionIdxSnapshot = subscriptionIdx + this.job = launch { + val db = databaseManager.paymentsDb() + db.listOutgoingInFlightPaymentsAsFlow( + count = countSnapshot.toLong(), + skip = offsetSnapshot.toLong() + ).collect { rows -> + if (subscriptionIdxSnapshot == subscriptionIdx) { + _paymentsPage.value = PaymentsPage( + offset = offsetSnapshot, + count = countSnapshot, + rows = rows + ) + } + } + } + } + + fun subscribeToRecent(offset: Int, count: Int, seconds: Int) { + log.debug { "subscribeToRecent(offset=$offset, count=$count, seconds=$seconds)" } + + if (seconds <= 0) { + subscribeToInFlight(offset = offset, count = count) + return + } + if (this.offset == offset && this.count == count && this.seconds == seconds) { + // No changes + log.debug { "ignoring: no changes" } + return + } + + this.offset = offset.coerceAtLeast(minimumValue = 0) + this.count = count.coerceAtLeast(minimumValue = 1) + this.seconds = seconds + this.subscriptionIdx += 1 + + resetSubscribeToRecentJob(subscriptionIdx) + } + + private fun resetSubscribeToRecentJob(idx: Int) { + log.debug { "resetSubscribeToRecentJob(idx=$idx)" } + + if (idx != subscriptionIdx) { + log.debug { "resetSubscribeToRecentJob: ignoring: idx mismatch"} + return + } + job?.let { + log.debug { "cancelling previous job" } + it.cancel() + job = null + } + + val offsetSnapshot = offset + val countSnapshot = count + val secondsSnapshot = seconds + val subscriptionIdxSnapshot = subscriptionIdx + job = launch { + val db = databaseManager.paymentsDb() + val date = Clock.System.now() - secondsSnapshot.seconds + db.listRecentPaymentsAsFlow( + count = countSnapshot.toLong(), + skip = offsetSnapshot.toLong(), + sinceDate = date.toEpochMilliseconds() + ).collect { rows -> + if (subscriptionIdxSnapshot == subscriptionIdx) { + _paymentsPage.value = PaymentsPage( + offset = offsetSnapshot, + count = countSnapshot, + rows = rows + ) + resetRefreshJob(idx, rows) + } + } + } + } + + private fun resetRefreshJob(idx: Int, rows: List) { + log.debug { "resetRefreshJob(idx=$idx, rows=${rows.size})" } + + if (idx != subscriptionIdx) { + log.debug { "resetRefreshJob: ignoring: idx mismatch"} + return + } + this.refreshJob?.let { + log.debug { "cancelling previous refreshJob" } + it.cancel() + this.refreshJob = null + } + if (this.seconds <= 0) { + // The refreshJob isn't needed in this scenario + return + } + + val oldestCompleted = rows.mapNotNull { it.payment.completedAt }.lastOrNull() ?: return + val oldestTimestamp = Instant.fromEpochMilliseconds(oldestCompleted) + + val refreshTimestamp = oldestTimestamp + this.seconds.seconds + val diff = refreshTimestamp - Clock.System.now() + + // log.debug { "oldestTimestamp: ${oldestTimestamp.toEpochMilliseconds()}"} + // log.debug { "refreshTimestamp: ${refreshTimestamp.toEpochMilliseconds()}"} + // log.debug { "diff=${diff}"} + + this.refreshJob = launch { + delay(diff) + resetSubscribeToRecentJob(idx) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PeerManager.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PeerManager.kt new file mode 100644 index 00000000..550ea39d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PeerManager.kt @@ -0,0 +1,281 @@ +package fr.acinq.phoenix.managers + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.lightning.CltvExpiryDelta +import fr.acinq.lightning.DefaultSwapInParams +import fr.acinq.lightning.InvoiceDefaultRoutingFees +import fr.acinq.lightning.LiquidityEvents +import fr.acinq.lightning.NodeParams +import fr.acinq.lightning.SwapInParams +import fr.acinq.lightning.TrampolineFees +import fr.acinq.lightning.UpgradeRequired +import fr.acinq.lightning.WalletParams +import fr.acinq.lightning.blockchain.electrum.ElectrumClient +import fr.acinq.lightning.blockchain.electrum.ElectrumWatcher +import fr.acinq.lightning.blockchain.electrum.FinalWallet +import fr.acinq.lightning.blockchain.electrum.IElectrumClient +import fr.acinq.lightning.blockchain.electrum.SwapInManager +import fr.acinq.lightning.blockchain.electrum.SwapInWallet +import fr.acinq.lightning.blockchain.electrum.WalletState +import fr.acinq.lightning.blockchain.fee.FeeratePerByte +import fr.acinq.lightning.channel.states.ChannelStateWithCommitments +import fr.acinq.lightning.channel.states.Normal +import fr.acinq.lightning.channel.states.Offline +import fr.acinq.lightning.channel.states.PersistedChannelState +import fr.acinq.lightning.io.Peer +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.payment.LiquidityPolicy +import fr.acinq.lightning.utils.Connection +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sat +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.data.LocalChannelInfo +import fr.acinq.phoenix.utils.extensions.isTerminated +import fr.acinq.phoenix.utils.extensions.nextTimeout +import fr.acinq.lightning.logging.debug +import fr.acinq.lightning.logging.error +import fr.acinq.lightning.logging.info +import fr.acinq.phoenix.managers.global.FeerateManager +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + + +class PeerManager( + loggerFactory: LoggerFactory, + private val nodeParamsManager: NodeParamsManager, + private val databaseManager: DatabaseManager, + private val configurationManager: AppConfigurationManager, + private val feerateManager: FeerateManager, + private val notificationsManager: NotificationsManager, + private val electrumClient: ElectrumClient, + private val electrumWatcher: ElectrumWatcher, +) : CoroutineScope by CoroutineScope(CoroutineName("peer") + SupervisorJob() + Dispatchers.Main + CoroutineExceptionHandler { _, e -> + println("error in Peer coroutine scope: ${e.message}") + val logger = loggerFactory.newLogger("PeerManager") + logger.error(e) { "error in Peer scope: " } +}) { + + constructor(business: PhoenixBusiness) : this( + loggerFactory = business.loggerFactory, + nodeParamsManager = business.nodeParamsManager, + databaseManager = business.databaseManager, + configurationManager = business.appConfigurationManager, + feerateManager = business.phoenixGlobal.feerateManager, + notificationsManager = business.notificationsManager, + electrumClient = business.electrumClient, + electrumWatcher = business.electrumWatcher, + ) + + private val logger = loggerFactory.newLogger(this::class) + + private val _peer = MutableStateFlow(null) + val peerState: StateFlow = _peer + + /** + * Our local view of our channels. It is initialized with data from the local db, then with the actual + * channels once they have been reestablished. + */ + private val _channelsFlow = MutableStateFlow?>(null) + val channelsFlow: StateFlow?> = _channelsFlow + + /** Forward compatibility check. [UpgradeRequired] is sent by the peer when an old version of Phoenix restores a wallet that has been used with new channel types. */ + private val _upgradeRequired = MutableStateFlow(false) + val upgradeRequired = _upgradeRequired.asStateFlow() + + /** Flow of the peer's final wallet [WalletState.WalletWithConfirmations]. */ + @OptIn(ExperimentalCoroutinesApi::class) + val finalWallet = peerState.filterNotNull().flatMapLatest { peer -> + combine(peer.currentTipFlow.filterNotNull(), peer.phoenixFinalWallet.wallet.walletStateFlow) { currentBlockHeight, wallet -> + wallet.withConfirmations( + currentBlockHeight = currentBlockHeight, + // the final wallet does not need to distinguish between weak/deep/locked txs + swapInParams = SwapInParams( + minConfirmations = 0, + maxConfirmations = Int.MAX_VALUE, + refundDelay = Int.MAX_VALUE, + ) + ) + } + }.stateIn( + scope = this, + started = SharingStarted.Lazily, + initialValue = null, + ) + + /** + * Flow of the peer's swap-in wallet [WalletState.WalletWithConfirmations], without the utxos reserved for channels. + * + * Utxos that are reserved for channels are excluded. This prevents a scenario where a channel is being created - and + * the Lightning balance is updated - but the utxos for this channel are not yet spent and are such still listed in + * the swap-in wallet flow. The UI would be incorrect for a while. + * + * See [SwapInManager.reservedWalletInputs] for details. + */ + @OptIn(ExperimentalCoroutinesApi::class) + val swapInWallet = peerState.filterNotNull().flatMapLatest { peer -> + combine(peer.currentTipFlow.filterNotNull(), peer.channelsFlow, peer.phoenixSwapInWallet.wallet.walletStateFlow) { currentBlockHeight, channels, swapInWallet -> + val reservedInputs = SwapInManager.reservedWalletInputs(channels.values.filterIsInstance()) + val walletWithoutReserved = swapInWallet.withoutReservedUtxos(reservedInputs) + walletWithoutReserved.withConfirmations( + currentBlockHeight = currentBlockHeight, + swapInParams = peer.walletParams.swapInParams + ) + } + }.stateIn( + scope = this, + started = SharingStarted.Lazily, + initialValue = null, + ) + + /** + * Provides a recommended fee rate for various user operations in the app (e.g., splice-out, simple close, cpfp). + * Returns the mempool.space half-hour estimation if available, falls back to the peer funding feerate if not, and worst case, returns a static base value of 3s/vb. + */ + @OptIn(ExperimentalCoroutinesApi::class) + val recommendedFeerateFlow = peerState.filterNotNull().flatMapLatest { peer -> + combine(feerateManager.mempoolFeerate, peer.peerFeeratesFlow) { mempoolFeerate, peerFeerates -> + mempoolFeerate?.halfHour + ?: peerFeerates?.fundingFeerate?.let { FeeratePerByte(it) } + ?: FeeratePerByte(3.sat) + } + }.stateIn( + scope = this, + started = SharingStarted.Eagerly, + initialValue = FeeratePerByte(3.sat) + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val swapInNextTimeout = swapInWallet.filterNotNull().mapLatest { it.nextTimeout } + + /** + * FIXME: Temporary workaround. Must be done in lightning-kmp with proper testing. + * See [Peer.waitForPeerReady] + * + * Return true if peer is connected & channels are normal. Terminated channels are ignored. + */ + @OptIn(ExperimentalCoroutinesApi::class) + val mayDoPayments = peerState.filterNotNull().flatMapLatest { peer -> + combine(peer.connectionState, peer.channelsFlow) { connectionState, channels -> + when { + connectionState !is Connection.ESTABLISHED -> false + channels.isEmpty() -> true + else -> channels.values.filterNot { it.isTerminated() }.all { it is Normal } + } + } + }.stateIn( + scope = this, + started = SharingStarted.Lazily, + initialValue = false + ) + + init { + launch { + val nodeParams = nodeParamsManager.nodeParams.filterNotNull().first() + val startupParams = configurationManager.startupParams.filterNotNull().first() + + val walletParams = WalletParams( + trampolineNode = if (startupParams.isTorEnabled) NodeParamsManager.trampolineNodeOnionUri else NodeParamsManager.trampolineNodeUri, + trampolineFees = listOf( + TrampolineFees( + feeBase = 4.sat, + feeProportional = 4_000, + cltvExpiryDelta = CltvExpiryDelta(576) + ) + ), + invoiceDefaultRoutingFees = InvoiceDefaultRoutingFees( + feeBase = 1_000.msat, + feeProportional = 100, + cltvExpiryDelta = CltvExpiryDelta(144) + ), + swapInParams = SwapInParams( + minConfirmations = DefaultSwapInParams.MinConfirmations, + maxConfirmations = DefaultSwapInParams.MaxConfirmations, + refundDelay = DefaultSwapInParams.RefundDelay, + ), + ) + + logger.info { "instantiating peer with:\n walletParams=$walletParams\n startupParams=$startupParams" } + + val peer = Peer( + nodeParams = nodeParams, + walletParams = walletParams, + client = electrumClient, + watcher = electrumWatcher, + db = databaseManager.databases.filterNotNull().first(), + socketBuilder = null, + scope = MainScope() + ) + _peer.value = peer + + launch { monitorNodeEvents(nodeParams) } + + // The local channels flow must use `bootFlow` first, as `channelsFlow` is empty when the wallet starts. + // `bootFlow` data come from the local database and will be overridden by fresh data once the connection + // with the peer has been established. + val bootFlow = peer.bootChannelsFlow.filterNotNull() + val channelsFlow = peer.channelsFlow + var isBoot = true + + combine(bootFlow, channelsFlow) { bootChannels, channels -> + // bootFlow will fire once, after the channels have been read from the database. + if (isBoot) { + isBoot = false + bootChannels.entries.associate { it.key to LocalChannelInfo(it.key.toHex(), it.value, isBooting = true) } + } else { + channels.entries.associate { it.key to LocalChannelInfo(it.key.toHex(), it.value, isBooting = false) } + } + }.collect { + _channelsFlow.value = it + } + } + } + + suspend fun getPeer() = peerState.filterNotNull().first() + + /** + * Returns the underlying channel, if it's of type ChannelStateWithCommitments. + * Note that Offline channels are automatically unwrapped. + */ + fun getChannelWithCommitments(channelId: ByteVector32): ChannelStateWithCommitments? { + val peer = peerState.value ?: return null + var channel = peer.channels[channelId] ?: return null + channel = when (channel) { + is Offline -> channel.state + else -> channel + } + return when (channel) { + is ChannelStateWithCommitments -> channel + else -> null + } + } + + /** Override the liquidity policy setting used by the node. */ + suspend fun updatePeerLiquidityPolicy(newPolicy: LiquidityPolicy) { + getPeer().nodeParams.liquidityPolicy.value = newPolicy + } + + private suspend fun monitorNodeEvents(nodeParams: NodeParams) { + nodeParams.nodeEvents.collect { event -> + logger.debug { "collecting node_event=${event::class.simpleName}" } + when (event) { + is LiquidityEvents.Rejected -> { + notificationsManager.saveLiquidityEventNotification(event) + } + + is UpgradeRequired -> { + _upgradeRequired.value = true + } + + else -> {} + } + } + } +} + +/** The peer's swap-in wallet for Phoenix is always not null, because the client is always an [IElectrumClient] (see how this Peer is built in `PeerManager.init`). */ +val Peer.phoenixSwapInWallet: SwapInWallet + get() = this.swapInWallet!! + +/** The peer's final wallet for Phoenix is always not null, because the client is always an [IElectrumClient] (see how this Peer is built in `PeerManager.init`). */ +val Peer.phoenixFinalWallet: FinalWallet + get() = this.finalWallet!! diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PinManager.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PinManager.kt new file mode 100644 index 00000000..9c392809 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PinManager.kt @@ -0,0 +1,142 @@ +package fr.acinq.phoenix.managers + +import co.touchlab.kermit.Logger +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.security.EncryptedPinLock +import fr.acinq.phoenix.security.EncryptedPinSpending +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toPath +import okio.SYSTEM +import okio.buffer +import okio.use + +object PinManager { + val log = Logger.withTag("PinManager") + + private const val LOCK_PIN_FILE_NAME = "pin.dat" + private const val SPENDING_PIN_FILE_NAME = "spending_pin.dat" + + private fun getDataDir(): Path { + val datadir = SeedManager.getDatadir() + if (!FileSystem.SYSTEM.exists(datadir)) { + FileSystem.SYSTEM.createDirectory(datadir) + } + return datadir + } + + private fun getEncryptedPinFromDisk(fileName: String): ByteArray? { + val encryptedPinFile = getDataDir().resolve(fileName) + val encryptedPinFileMetadata = FileSystem.SYSTEM.metadataOrNull(encryptedPinFile) + + return if (!FileSystem.SYSTEM.exists(encryptedPinFile)) { + null + } else if (encryptedPinFileMetadata == null) { + log.w("$fileName is unreadable") + null + } else if (!encryptedPinFileMetadata.isRegularFile) { + log.w("$fileName is not a file") + null + // TODO: Check if file is writable +// } else if (!encryptedPinFile.isFile || !encryptedPinFile.canRead() || !encryptedPinFile.canWrite()) { +// log.warn("$fileName exists but is not usable") +// null + } else { + FileSystem.SYSTEM.source(encryptedPinFile).buffer().use { source -> + source.readByteArray().let { + if (it.isEmpty()) { + null + } else { + it + } + } + } + } + } + + fun getLockPinMapFromDisk(): Map { + val encryptedPin = getEncryptedPinFromDisk( LOCK_PIN_FILE_NAME)?.let { + EncryptedPinLock.Companion.deserialize(it) + } + return when (encryptedPin) { + is EncryptedPinLock.SingleWallet -> { + log.w("[SingleWallet] lock-pin, migration should be performed") + emptyMap() + } + is EncryptedPinLock.MultipleWallet -> encryptedPin.decryptAndGetPins() + null -> emptyMap() + } + } + + fun getSpendingPinMapFromDisk(): Map { + val encryptedPin = getEncryptedPinFromDisk(SPENDING_PIN_FILE_NAME)?.let { + EncryptedPinSpending.Companion.deserialize(it) + } + return when (encryptedPin) { + is EncryptedPinSpending.SingleWallet -> { + log.w("[SingleWallet] spending-pin, migration should be performed") + emptyMap() + } + is EncryptedPinSpending.MultipleWallet -> encryptedPin.decryptAndGetPins() + null -> emptyMap() + } + } + + fun writeLockPinMapToDisk(pinMap: Map) { + val encryptedPin = EncryptedPinLock.Companion.encrypt(pinMap) + val datadir = getDataDir() + val temp = datadir.resolve("temporary_pin.dat") + + FileSystem.SYSTEM.write(temp) { + write(encryptedPin.serialize(EncryptedPinLock.MULTIPLE_WALLET_VERSION.toInt())) + } + + FileSystem.SYSTEM.copy( + source = temp, + target = datadir.resolve(LOCK_PIN_FILE_NAME.toPath()) + ) + FileSystem.SYSTEM.delete(temp) + } + + fun writeSpendingPinMapToDisk(pinMap: Map) { + val encryptedPin = EncryptedPinSpending.Companion.encrypt(pinMap) + val datadir = getDataDir() + + val temp = datadir.resolve("temporary_pin.dat") + FileSystem.SYSTEM.write(temp) { + write(encryptedPin.serialize(EncryptedPinSpending.MULTIPLE_WALLET_VERSION.toInt())) + } + + FileSystem.SYSTEM.copy( + source = temp, + target = datadir.resolve(SPENDING_PIN_FILE_NAME.toPath()) + ) + FileSystem.SYSTEM.delete(temp) + } + + fun migrateSingleWalletPinCode(walletId: WalletId) { + val encryptedLockPin = getEncryptedPinFromDisk(LOCK_PIN_FILE_NAME)?.let { + EncryptedPinLock.deserialize(it) + } + when (encryptedLockPin) { + is EncryptedPinLock.SingleWallet -> { + val oldPin = encryptedLockPin.decrypt().decodeToString() + writeLockPinMapToDisk(mapOf(walletId to oldPin)) + log.i("migrated lock-pin for wallet=$walletId") + } + else -> Unit + } + + val encryptedSpendingPin = getEncryptedPinFromDisk( SPENDING_PIN_FILE_NAME)?.let { + EncryptedPinSpending.Companion.deserialize(it) + } + when (encryptedSpendingPin) { + is EncryptedPinSpending.SingleWallet -> { + val oldPin = encryptedSpendingPin.decrypt().decodeToString() + writeSpendingPinMapToDisk(mapOf(walletId to oldPin)) + log.i("migrated spending-pin for wallet=$walletId") + } + else -> Unit + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/SeedManager.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/SeedManager.kt new file mode 100644 index 00000000..9b423a0c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/SeedManager.kt @@ -0,0 +1,182 @@ +package fr.acinq.phoenix.managers + +import co.touchlab.kermit.Logger +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.lightning.crypto.LocalKeyManager +import fr.acinq.lightning.utils.toByteVector +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.DecryptSeedResult +import fr.acinq.phoenix.data.UserWallet +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.security.EncryptedSeed +import fr.acinq.phoenix.utils.extensions.gracefulMultiSeedDecryption +import fr.acinq.phoenix.utils.extensions.gracefulSingleSeedDecryption +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toPath +import okio.SYSTEM +import okio.buffer +import okio.use + +object SeedManager { + private val BASE_DATADIR = FileSystem.SYSTEM_TEMPORARY_DIRECTORY.resolve( "node-data") + private val SEED_FILE = "seed.dat" + private val log = Logger.withTag("SeedManager") + + init { + log.i("Canonical: $BASE_DATADIR") + } + fun getDatadir(): Path { + + if (!FileSystem.SYSTEM.exists(BASE_DATADIR)) { + log.i("Base directory doesn't exist: $BASE_DATADIR") + FileSystem.SYSTEM.createDirectory(BASE_DATADIR) + } + + log.i("Base directory: $BASE_DATADIR") + return BASE_DATADIR + } + + @Suppress("DEPRECATION") + fun loadAndDecrypt(phoenixGlobal: PhoenixGlobal): DecryptSeedResult { + log.i("loadAndDecrypt") + val encryptedSeed = try { + loadEncryptedSeedFromDisk(phoenixGlobal) + } catch (e: Exception) { + log.e("couldn't read seed file: ", e) + return DecryptSeedResult.Failure.SeedFileUnreadable + } + + return when (encryptedSeed) { + is EncryptedSeed.V2.SingleSeed -> { + log.i("decrypting [V2.SingleSeed]...") + + gracefulSingleSeedDecryption { + val payload = encryptedSeed.decrypt() + + val words = EncryptedSeed.V2.SingleSeed.toMnemonicsSafe(payload) ?: return DecryptSeedResult.Failure.SeedInvalid + + val seed = MnemonicCode.toSeed(words, "").toByteVector() + val keyManager = LocalKeyManager( + seed, + NodeParamsManager.chain, + NodeParamsManager.remoteSwapInXpub + ) + val nodeId = keyManager.nodeKeys.nodeKey.publicKey + val walletId = WalletId(nodeId) + + PinManager.migrateSingleWalletPinCode(walletId) + + DecryptSeedResult.Success( + userWalletsMap = mapOf( + walletId to UserWallet( + walletId, + nodeId.toHex(), + words + ) + ) + ) + } + } + + is EncryptedSeed.V2.MultipleSeed -> { + log.i("decrypting [V2.MultipleSeed]") + gracefulMultiSeedDecryption { + val seedMap = encryptedSeed.decryptAndGetSeedMap() + + when { + seedMap.isEmpty() -> DecryptSeedResult.Failure.SeedFileNotFound + else -> { + seedMap.map { (walletId, words) -> + val seed = MnemonicCode.toSeed(words, "").toByteVector() + val keyManager = LocalKeyManager( + seed, + NodeParamsManager.chain, + NodeParamsManager.remoteSwapInXpub + ) + val nodeId = keyManager.nodeKeys.nodeKey.publicKey + walletId to UserWallet(walletId, nodeId.toHex(), words) + }.toMap().let { + DecryptSeedResult.Success(it) + } + } + } + } + } + + null -> DecryptSeedResult.Failure.SeedFileNotFound + } + } + + /** + * Wrapper method for [loadAndDecrypt]. + * Returns an empty map if the seed file does not exist yet. + * Returns null if there was a problem when loading or decrypting the seed file. + */ + suspend fun loadAndDecryptOrNull(phoenixGlobal: PhoenixGlobal): Map? = when (val res = loadAndDecrypt(phoenixGlobal)) { + is DecryptSeedResult.Success -> res.userWalletsMap + is DecryptSeedResult.Failure.SeedFileNotFound -> emptyMap() + is DecryptSeedResult.Failure -> null + } + + /** Gets the encrypted seed from app private dir. */ + fun loadEncryptedSeedFromDisk(phoenixGlobal: PhoenixGlobal): EncryptedSeed? = loadSeedFromDir(getDatadir(), SEED_FILE) + + /** Extracts an encrypted seed contained in a given file/folder. Returns null if the file does not exist. */ + private fun loadSeedFromDir(dir: Path, seedFileName: String): EncryptedSeed? { +// val seedFile = File(dir, seedFileName) + val seedFile = dir.resolve(seedFileName) + val seedFileMetadata = FileSystem.SYSTEM.metadataOrNull(seedFile) + log.i("Seed file metadata: $seedFileMetadata") + + return if (!FileSystem.SYSTEM.exists(seedFile)) { + log.i("seed file doesn't exist") + null + } else if (seedFileMetadata == null) { + throw UnreadableSeed("file is unreadable") + } else if (seedFileMetadata.isRegularFile != true) { + throw UnreadableSeed("not a file") + } else { + FileSystem.SYSTEM.source(seedFile).buffer().use { source -> + source.readByteArray().let { + if (it.isEmpty()) { + throw UnreadableSeed("empty file!") + } else { + EncryptedSeed.deserialize(it) + } + } + } + } + } + + fun writeSeedToDisk(phoenixGlobal: PhoenixGlobal, seed: EncryptedSeed.V2.MultipleSeed, overwrite: Boolean = false) = writeSeedToDir(getDatadir(), seed, overwrite) + + private fun writeSeedToDir(dir: Path, seed: EncryptedSeed.V2.MultipleSeed, overwrite: Boolean) { + // 1 - create dir + if (!FileSystem.SYSTEM.exists(dir)) { + FileSystem.SYSTEM.createDirectories(dir) + } + + // 2 - encrypt and write in a temporary file + val temp = dir.resolve("temporary_seed.dat".toPath()) + + FileSystem.SYSTEM.write(temp) { + write(seed.serialize()) + } + + // 3 - decrypt temp file and check validity; if correct, move temp file to final file + val checkSeed = loadSeedFromDir(dir, temp.name) as EncryptedSeed.V2.MultipleSeed + if (!checkSeed.ciphertext.contentEquals(seed.ciphertext)) { + log.w("seed check do not match!") +// throw WriteErrorCheckDontMatch + } + FileSystem.SYSTEM.copy( + source = temp, + target = dir.resolve(SEED_FILE.toPath()) + ) + FileSystem.SYSTEM.delete(temp) + } + + object WriteErrorCheckDontMatch : RuntimeException("failed to write the seed to disk: temporary file do not match") + class UnreadableSeed(msg: String) : RuntimeException(msg) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/SendManager.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/SendManager.kt new file mode 100644 index 00000000..bb6b170f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/SendManager.kt @@ -0,0 +1,668 @@ +package fr.acinq.phoenix.managers + +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import fr.acinq.bitcoin.BitcoinError +import fr.acinq.bitcoin.Chain +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.utils.Either +import fr.acinq.lightning.Lightning +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.TrampolineFees +import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.lightning.io.OfferInvoiceReceived +import fr.acinq.lightning.io.OfferNotPaid +import fr.acinq.lightning.io.PayInvoice +import fr.acinq.lightning.io.PayOffer +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.logging.debug +import fr.acinq.lightning.logging.error +import fr.acinq.lightning.payment.Bolt11Invoice +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.currentTimestampSeconds +import fr.acinq.lightning.wire.OfferTypes +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.data.BitcoinUri +import fr.acinq.phoenix.data.BitcoinUriError +import fr.acinq.phoenix.data.LnurlPayMetadata +import fr.acinq.phoenix.data.WalletPaymentMetadata +import fr.acinq.phoenix.data.lnurl.Lnurl +import fr.acinq.phoenix.data.lnurl.LnurlAuth +import fr.acinq.phoenix.data.lnurl.LnurlError +import fr.acinq.phoenix.data.lnurl.LnurlPay +import fr.acinq.phoenix.data.lnurl.LnurlWithdraw +import fr.acinq.phoenix.utils.DnsResolvers +import fr.acinq.phoenix.utils.EmailLikeAddress +import fr.acinq.phoenix.utils.Parser +import io.ktor.http.Url +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource + +class SendManager( + loggerFactory: LoggerFactory, + private val peerManager: PeerManager, + private val lnurlManager: LnurlManager, + private val databaseManager: DatabaseManager, + private val chain: Chain, +) : CoroutineScope by MainScope() { + + constructor(business: PhoenixBusiness): this( + loggerFactory = business.loggerFactory, + peerManager = business.peerManager, + lnurlManager = business.lnurlManager, + databaseManager = business.databaseManager, + chain = business.chain + ) + + private val log = loggerFactory.newLogger(this::class) + + sealed class BadRequestReason : Exception() { + data object UnknownFormat : BadRequestReason() + data object AlreadyPaidInvoice : BadRequestReason() + data object PaymentPending : BadRequestReason() + data class Expired(val timestampSeconds: Long, val expirySeconds: Long) : BadRequestReason() + data class ChainMismatch(val expected: Chain) : BadRequestReason() + data class ServiceError(val url: Url, val error: LnurlError.RemoteFailure) : BadRequestReason() + data class InvalidLnurl(val url: Url) : BadRequestReason() + data class Bip353Unresolved(val username: String, val domain: String) : BadRequestReason() + data class Bip353NameNotFound(val username: String, val domain: String) : BadRequestReason() + data class Bip353InvalidUri(val path: String) : BadRequestReason() + data class Bip353InvalidOffer(val path: String) : BadRequestReason() + data class Bip353NoDNSSEC(val path: String) : BadRequestReason() + data class UnsupportedLnurl(val url: Url) : BadRequestReason() + } + + sealed class LnurlPayError { + data class RemoteError(val err: LnurlError.RemoteFailure) : LnurlPayError() + data class BadResponseError(val err: LnurlError.Pay.Invoice) : LnurlPayError() + data class ChainMismatch(val expected: Chain) : LnurlPayError() + data object AlreadyPaidInvoice : LnurlPayError() + data object PaymentPending : LnurlPayError() + } + + sealed class LnurlWithdrawError { + data class RemoteError(val err: LnurlError.RemoteFailure) : LnurlWithdrawError() + } + + sealed class LnurlAuthError { + data class ServerError(val details: LnurlError.RemoteFailure) : LnurlAuthError() + data class NetworkError(val details: Throwable) : LnurlAuthError() + data class OtherError(val details: Throwable) : LnurlAuthError() + } + + sealed class ParseProgress { + data object LnurlServiceFetch: ParseProgress() + data object ResolvingBip353: ParseProgress() + } + + sealed class ParseResult { + + data class BadRequest( + val request: String, + val reason: BadRequestReason + ): ParseResult() + + sealed class Success : ParseResult() + data class Bolt11Invoice( + val request: String, + val invoice: fr.acinq.lightning.payment.Bolt11Invoice + ): Success() + + data class Bolt12Offer( + val offer: OfferTypes.Offer, + val lightningAddress: String? + ): Success() + + data class Uri( + val uri: BitcoinUri + ): Success() + + sealed class Lnurl: Success() { + data class Pay( + val paymentIntent: LnurlPay.Intent, + val lightningAddress: String? + ): Lnurl() + + data class Withdraw( + val lnurlWithdraw: LnurlWithdraw, + ): Lnurl() + + data class Auth( + val auth: LnurlAuth + ): Lnurl() + } + } + + suspend fun parse( + request: String, + progress: (p: ParseProgress) -> Unit + ): ParseResult { + + val input = Parser.removeExcessInput(request) + return try { + Parser.readBolt11Invoice(input)?.let { + processBolt11Invoice(it) + } ?: Parser.readOffer(input)?.let { + processOffer(it, null) + } ?: readEmailLikeAddress(input, progress)?.let { + when (it) { + is Either.Left -> processOffer(it.value, input) + is Either.Right -> processLnurl(it.value, input, progress) + } + } ?: readLnurl(input)?.let { + processLnurl(it, null, progress) + } ?: readBitcoinAddress(input)?.let { + processBitcoinAddress(input, it) + } ?: readLNURLFallback(input)?.let { + processLnurl(it, null, progress) + } ?: run { + ParseResult.BadRequest( + request = request, + reason = BadRequestReason.UnknownFormat + ) + } + } catch (e: Exception) { + if (e is BadRequestReason) { + ParseResult.BadRequest( + request = request, + reason = e + ) + } else { + ParseResult.BadRequest( + request = request, + reason = BadRequestReason.UnknownFormat + ) + } + } + } + + /** Inspects the Lightning invoice for errors and update the model with the adequate value. */ + private suspend fun processBolt11Invoice( + invoice: Bolt11Invoice + ): ParseResult { + + return checkForBadBolt11Invoice(invoice)?.let { + ParseResult.BadRequest(request = invoice.write(), reason = it) + } ?: ParseResult.Bolt11Invoice( + request = invoice.write(), + invoice = invoice, + ) + } + + private suspend fun checkForBadBolt11Invoice( + invoice: Bolt11Invoice + ): BadRequestReason? { + + val actualChain = invoice.chain + if (chain != actualChain) { + return BadRequestReason.ChainMismatch(expected = chain) + } + + if (invoice.isExpired(currentTimestampSeconds())) { + return BadRequestReason.Expired(invoice.timestampSeconds, invoice.expirySeconds ?: Bolt11Invoice.DEFAULT_EXPIRY_SECONDS.toLong()) + } + + val db = databaseManager.databases.filterNotNull().first() + val similarPayments = db.payments.listLightningOutgoingPayments(invoice.paymentHash) + // we MUST raise an error if this payment hash has already been paid, or is being paid. + // parallel pending payments on the same payment hash can trigger force-closes + // FIXME: this check should be done in lightning-kmp, not in Phoenix + return when { + similarPayments.any { it.status is LightningOutgoingPayment.Status.Succeeded || it.parts.any { part -> part.status is LightningOutgoingPayment.Part.Status.Succeeded } } -> + BadRequestReason.AlreadyPaidInvoice + similarPayments.any { it.status is LightningOutgoingPayment.Status.Pending || it.parts.any { part -> part.status is LightningOutgoingPayment.Part.Status.Pending } } -> + BadRequestReason.PaymentPending + else -> null + } + } + + private fun processOffer( + offer: OfferTypes.Offer, + lightningAddress: String? + ): ParseResult { + + return if (!offer.chains.contains(chain.chainHash)) { + ParseResult.BadRequest( + request = offer.encode(), + reason = BadRequestReason.ChainMismatch(expected = chain) + ) + } else { + ParseResult.Bolt12Offer(offer, lightningAddress) + } + } + + @Throws(BadRequestReason::class, CancellationException::class) + private suspend fun readEmailLikeAddress( + input: String, + progress: (p: ParseProgress) -> Unit + ): Either? { + + if (!input.contains("@", ignoreCase = true)) return null + + val address = Parser.parseEmailLikeAddress(input) ?: return null + + return when (address) { + is EmailLikeAddress.Bip353 -> { + progress(ParseProgress.ResolvingBip353) + resolveBip353Offer(address.username, address.domain)?.let { Either.Left(it) } + } + is EmailLikeAddress.LnurlBased -> { + progress(ParseProgress.LnurlServiceFetch) + Either.Right(address.url) + } + is EmailLikeAddress.UnknownType -> { + resolveBip353Offer(address.username.dropWhile { it == '₿' }, address.domain)?.let { Either.Left(it) } + ?: Either.Right(EmailLikeAddress.LnurlBased(address.source, address.username, address.domain).url) + } + } + } + + /** + * Resolve dns-based offers. + * See https://github.com/bitcoin/bips/blob/master/bip-0353.mediawiki. + */ + @Throws(BadRequestReason::class, CancellationException::class) + private suspend fun resolveBip353Offer( + username: String, + domain: String, + ): OfferTypes.Offer? { + + val dnsPath = "$username.user._bitcoin-payment.$domain." + + val json = try { + DnsResolvers.getRandom().getTxtRecord(dnsPath) + } catch (e: Exception) { + throw BadRequestReason.Bip353Unresolved(username, domain) + } + log.debug { "dns resolved to ${json.toString().take(100)}" } + + val status = json["Status"]?.jsonPrimitive?.intOrNull + // could be a [BadRequestReason.Bip353NameNotFound] it status == 3 + if (status == null || status > 0) return null + + val records = json["Answer"]?.jsonArray + if (records.isNullOrEmpty()) { + log.debug { "no answer for $dnsPath" } + // TODO add test (see #599) + return null + } + + // check dnssec + val ad = json["AD"]?.jsonPrimitive?.booleanOrNull + if (ad != true) { + log.debug { "AD false, abort dns lookup" } + throw BadRequestReason.Bip353NoDNSSEC(dnsPath) + } + + // check name matches records + val matchingRecord = records.filterIsInstance().firstOrNull { + log.debug { "inspecting record=$it" } + it["name"]?.jsonPrimitive?.content == dnsPath + } ?: throw BadRequestReason.Bip353NameNotFound(username, domain) + + val data = matchingRecord["data"]?.jsonPrimitive?.content + ?: throw BadRequestReason.Bip353InvalidUri(dnsPath) + + return when (val res = Parser.parseBip21Uri(chain, data)) { + is Either.Left -> { + val error = res.value + if (error is BitcoinUriError.InvalidScript && error.error is BitcoinError.ChainHashMismatch) { + throw BadRequestReason.ChainMismatch(expected = chain) + } else { + throw BadRequestReason.Bip353InvalidUri(dnsPath) + } + } + is Either.Right -> { + res.value.offer ?: + throw BadRequestReason.Bip353InvalidOffer(dnsPath) + } + } + } + + private suspend fun processLnurl( + lnurl: Lnurl, + lightningAddress: String?, + progress: (p: ParseProgress) -> Unit + ): ParseResult? { + return when (lnurl) { + is LnurlAuth -> { + ParseResult.Lnurl.Auth(auth = lnurl) + } + // this lnurl is a standard url that must be executed immediately in order to get the actual + // details from the service (the service should return either a LnurlPay or a LnurlWithdraw). + is Lnurl.Request -> { + progress(ParseProgress.LnurlServiceFetch) + + val url = lnurl.initialUrl + val task = lnurlManager.executeLnurl(url) + try { + when (val result: Lnurl = task.await()) { + is LnurlPay.Intent -> { + ParseResult.Lnurl.Pay(paymentIntent = result, lightningAddress) + } + is LnurlWithdraw -> { + ParseResult.Lnurl.Withdraw(lnurlWithdraw = result) + } + else -> { + ParseResult.BadRequest( + request = url.toString(), + reason = BadRequestReason.UnsupportedLnurl(url) + ) + } + } + } catch (e: Exception) { + log.error { "failed to process lnurl: $e" } + log.error(e) { "failed to process lnurl=$lnurl" } + when (e) { + is LnurlError.RemoteFailure -> + ParseResult.BadRequest( + request = url.toString(), + reason = BadRequestReason.ServiceError(url, e) + ) + else -> + ParseResult.BadRequest( + request = url.toString(), + reason = BadRequestReason.InvalidLnurl(url) + ) + } + } + } + else -> null + } + } + + /** Reads a lnurl and return either a lnurl-auth (i.e. a http query that must not be called automatically), or the actual url embedded in the lnurl (that can be called afterwards). */ + private fun readLnurl(input: String): Lnurl? = try { + Lnurl.extractLnurl(input, log) + } catch (t: Throwable) { + null + } + + /** Invokes `Parser.readBitcoinAddress`, but maps [BitcoinUriError.InvalidUri] to a null result instead of a fatal error. */ + private fun readBitcoinAddress(input: String): Either? { + return when (val result = Parser.parseBip21Uri(chain, input)) { + is Either.Left -> when (result.left) { + is BitcoinUriError.InvalidUri -> null + else -> result + } + is Either.Right -> result + } + } + + /** Return the adequate model for a Bitcoin address result. */ + private fun processBitcoinAddress( + input: String, + result: Either + ): ParseResult { + return when (result) { + is Either.Right -> { + val address = result.value.address + val bolt11 = result.value.paymentRequest + val bolt12 = result.value.offer + when { + address.isNotBlank() -> ParseResult.Uri(uri = result.value) + bolt11 != null -> ParseResult.Bolt11Invoice(request = input, invoice = bolt11) + bolt12 != null -> ParseResult.Bolt12Offer(offer = bolt12, lightningAddress = null) + else -> ParseResult.BadRequest(request = input, reason = BadRequestReason.UnknownFormat) + } + } + is Either.Left -> { + val error = result.value + if (error is BitcoinUriError.InvalidScript && error.error is BitcoinError.ChainHashMismatch) { + ParseResult.BadRequest(request = input, reason = BadRequestReason.ChainMismatch(expected = chain)) + } else { + ParseResult.BadRequest(request = input, reason = BadRequestReason.UnknownFormat) + } + } + } + } + + /** + * Support for LNURL Fallback Scheme, + * e.g. as used by Bitcoin Beach Wallet's static Paycode QR. + * https://github.com/ACINQ/phoenix/issues/323 + */ + private fun readLNURLFallback(input: String): Lnurl? = try { + val url = Url(input) + url.parameters["lightning"]?.let { fallback -> + Lnurl.extractLnurl(fallback, log) + } + } catch (t: Throwable) { + null + } + + /** Extract invoice and send it to the Peer to make the payment, attaching custom trampoline fees if needed. */ + suspend fun payBolt11Invoice( + amountToSend: MilliSatoshi, + invoice: Bolt11Invoice, + metadata: WalletPaymentMetadata?, + ): UUID { + + val paymentId = UUID.randomUUID() + val peer = peerManager.getPeer() + + // save lnurl metadata if any + metadata?.let { row -> + databaseManager.paymentMetadataQueue.enqueue(row = row, id = paymentId) + } + + peer.send( + PayInvoice( + paymentId = paymentId, + amount = amountToSend, + paymentDetails = LightningOutgoingPayment.Details.Normal(paymentRequest = invoice), +// trampolineFeesOverride = listOf(trampolineFees) + ) + ) + + return paymentId + } + + suspend fun payBolt12Offer( + paymentId: UUID, + amount: MilliSatoshi, + offer: OfferTypes.Offer, + lightningAddress: String?, + payerKey: PrivateKey, + payerNote: String?, + fetchInvoiceTimeoutInSeconds: Int + ): OfferNotPaid? { + val peer = peerManager.getPeer() + + lightningAddress?.let { + val metadata = WalletPaymentMetadata(lightningAddress = it) + databaseManager.paymentMetadataQueue.enqueue(metadata, paymentId) + } + + val res = CompletableDeferred() + launch { + peer.eventsFlow.collect { + if (it is OfferNotPaid && it.request.paymentId == paymentId) { + res.complete(it) + cancel() + } else if (it is OfferInvoiceReceived && it.request.paymentId == paymentId) { + res.complete(null) + cancel() + } + } + } + peer.send(PayOffer( + paymentId = paymentId, + payerKey = payerKey, + payerNote = payerNote, + amount = amount, + offer = offer, + fetchInvoiceTimeout = fetchInvoiceTimeoutInSeconds.seconds + )) + return res.await() + } + + /** + * Step 1 of 2: + * First call this function to convert the LnurlPay.Intent into a LnurlPay.Invoice. + * + * Note: This step is cancellable. The UI can simply ignore the result. + */ + suspend fun lnurlPay_requestInvoice( + pay: ParseResult.Lnurl.Pay, + amount: MilliSatoshi, + comment: String? + ): Either { + val task = lnurlManager.requestPayInvoice( + intent = pay.paymentIntent, + amount = amount, + comment = comment + ) + return try { + val invoice = task.await() + when (checkForBadBolt11Invoice(invoice.invoice)) { + is BadRequestReason.ChainMismatch -> Either.Left(LnurlPayError.ChainMismatch(expected = chain)) + is BadRequestReason.AlreadyPaidInvoice -> Either.Left(LnurlPayError.AlreadyPaidInvoice) + is BadRequestReason.PaymentPending -> Either.Left(LnurlPayError.PaymentPending) + else -> Either.Right(invoice) + } + } catch (err: Throwable) { + when (err) { + is LnurlError.RemoteFailure -> Either.Left(LnurlPayError.RemoteError(err)) + is LnurlError.Pay.Invoice -> Either.Left(LnurlPayError.BadResponseError(err)) + else -> Either.Left( + LnurlPayError.RemoteError( + LnurlError.RemoteFailure.Unreadable( + origin = pay.paymentIntent.callback.host + ) + ) + ) + } + } + } + + /** + * Step 2 of 2: + * After fetching the LnurlPay.Invoice, use this function to send the payment. + * + * Note: This step is non-cancellable. + */ + suspend fun lnurlPay_payInvoice( + pay: ParseResult.Lnurl.Pay, + amount: MilliSatoshi, + comment: String?, + invoice: LnurlPay.Invoice + ): UUID { + return payBolt11Invoice( + amountToSend = amount, + invoice = invoice.invoice, + metadata = WalletPaymentMetadata( + lnurl = LnurlPayMetadata( + pay = pay.paymentIntent, + description = pay.paymentIntent.metadata.plainText, + successAction = invoice.successAction + ), + userNotes = comment, + lightningAddress = pay.lightningAddress + ) + ) + } + + /** + * Step 1 of 2: + * First call this function to convert the LnurlWithdraw into a Bolt11Invoice. + * + * Note: This step is cancellable. The UI can simply ignore the result. + */ + suspend fun lnurlWithdraw_createInvoice( + lnurlWithdraw: LnurlWithdraw, + amount: MilliSatoshi, + description: String? + ): Bolt11Invoice { + return peerManager.getPeer().createInvoice( + paymentPreimage = Lightning.randomBytes32(), + amount = amount, + description = Either.Left(description ?: lnurlWithdraw.defaultDescription), + expiry = 7.days, + ) + } + + /** + * Step 2 of 2: + * Sends the Bolt11Invoice to the corresponding host. + * + * Todo: We probably want to return a Deferred here instead. + * That would make it cancellable (to a certain degree). + */ + suspend fun lnurlWithdraw_sendInvoice( + lnurlWithdraw: LnurlWithdraw, + invoice: Bolt11Invoice + ): LnurlWithdrawError? { + val task = lnurlManager.sendWithdrawInvoice( + lnurlWithdraw = lnurlWithdraw, + paymentRequest = invoice + ) + return try { + task.await() + null + } catch (err: Throwable) { + when (err) { + is LnurlError.RemoteFailure -> { + LnurlWithdrawError.RemoteError(err) + } + else -> { // unexpected exception: map to generic error + LnurlWithdrawError.RemoteError( + LnurlError.RemoteFailure.Unreadable( + origin = lnurlWithdraw.callback.host + ) + ) + } + } + } + } + + suspend fun lnurlAuth_signAndSend( + auth: LnurlAuth, + minSuccessDelaySeconds: Double = 0.0, + scheme: LnurlAuth.Scheme + ): LnurlAuthError? { + return withContext(Dispatchers.Default) { + val start = TimeSource.Monotonic.markNow() + val error = try { + lnurlManager.signAndSendAuthRequest( + auth = auth, + scheme = scheme + ) + null + } catch (e: LnurlError.RemoteFailure.CouldNotConnect) { + LnurlAuthError.NetworkError(details = e) + } catch (e: LnurlError.RemoteFailure) { + LnurlAuthError.ServerError(details = e) + } catch (e: Throwable) { + LnurlAuthError.OtherError(details = e) + } + if (error != null) { + return@withContext error + } else { + val pending = minSuccessDelaySeconds.seconds - start.elapsedNow() + if (pending > Duration.ZERO) { + delay(pending) + } + return@withContext null + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/WalletManager.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/WalletManager.kt new file mode 100644 index 00000000..7e08950f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/WalletManager.kt @@ -0,0 +1,104 @@ +/* + * 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.managers + +import fr.acinq.bitcoin.* +import fr.acinq.lightning.crypto.Bip84OnChainKeys +import fr.acinq.lightning.crypto.KeyManager +import fr.acinq.lightning.crypto.LocalKeyManager +import fr.acinq.lightning.crypto.div +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.* + +class WalletManager( + private val chain: Chain +) : CoroutineScope by MainScope() { + + private val _localKeyManager = MutableStateFlow(null) + val keyManager: StateFlow = _localKeyManager + + fun isLoaded(): Boolean = keyManager.value != null + + /** Validates and converts a mnemonics list (stored app side) into a seed (usable by lightning-kmp). */ + fun mnemonicsToSeed( + mnemonics: List, + wordList: List, + passphrase: String = "" + ): ByteArray { + MnemonicCode.validate(mnemonics = mnemonics, wordlist = wordList) + return MnemonicCode.toSeed(mnemonics, passphrase) + } + + /** Loads a seed and creates the key manager. Returns an objet containing some keys for the iOS app. */ + fun loadWallet(seed: ByteArray): WalletInfo { + val km = keyManager.value ?: LocalKeyManager( + seed = seed.byteVector(), + chain = chain, + remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub, + ).also { + _localKeyManager.value = it + } + return WalletInfo( + nodeId = km.nodeKeys.nodeKey.publicKey, + nodeIdHash = km.nodeIdHash(), + cloudKey = km.cloudKey(), + cloudKeyHash = km.cloudKeyHash() + ) + } + + /** + * Utility wrapper for keys used by the wallet. + * + * @param nodeIdHash Hex of the hash160 of [nodeId]. + * We need to store data in the local filesystem that's associated with the + * specific nodeId, but we don't want to leak the nodeId. + * (i.e. We don't want to use the nodeId in cleartext anywhere). + * So we instead use the nodeIdHash as the identifier for local files. + * + * @param cloudKey + * We need a key to encypt/decrypt the blobs we store in the cloud. + * And we prefer this key to be seperate from other keys. + * + * @param cloudKeyHash + * Similar to the nodeIdHash, we need to store data in the cloud that's associated + * with the specific nodeId, but we don't want to leak the nodeId. + */ + data class WalletInfo( + val nodeId: PublicKey, + val nodeIdHash: String, // used for local storage + val cloudKey: ByteVector32, // used for cloud storage + val cloudKeyHash: String // used for cloud storage + ) +} + +fun LocalKeyManager.nodeIdHash(): String = this.nodeKeys.nodeKey.publicKey.hash160().byteVector().toHex() + +/** Key used to encrypt/decrypt blobs we store in the cloud. */ +fun LocalKeyManager.cloudKey(): ByteVector32 { + val path = KeyPath(if (isMainnet()) "m/51'/0'/0'/0" else "m/51'/1'/0'/0") + return derivePrivateKey(path).privateKey.value +} + +fun LocalKeyManager.cloudKeyHash(): String { + return Crypto.hash160(cloudKey()).byteVector().toHex() +} + +fun LocalKeyManager.isMainnet() = chain == Chain.Mainnet + +val LocalKeyManager.finalOnChainWalletPath: String + get() = (Bip84OnChainKeys.bip84BasePath(chain) / finalOnChainWallet.account).toString() diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/CurrencyManager.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/CurrencyManager.kt new file mode 100644 index 00000000..e0656b7a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/CurrencyManager.kt @@ -0,0 +1,424 @@ +/* + * 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.managers.global + +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.logging.debug +import fr.acinq.lightning.logging.info +import fr.acinq.phoenix.data.ExchangeRate +import fr.acinq.phoenix.data.FiatCurrency +import fr.acinq.phoenix.data.PreferredFiatCurrencies +import fr.acinq.phoenix.db.SqliteAppDb +import fr.acinq.phoenix.managers.global.fiatapis.BlockchainInfoApi +import fr.acinq.phoenix.managers.global.fiatapis.BluelyticsAPI +import fr.acinq.phoenix.managers.global.fiatapis.CoinbaseAPI +import fr.acinq.phoenix.managers.global.fiatapis.ExchangeRateApi +import fr.acinq.phoenix.managers.global.fiatapis.YadioAPI +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.time.Clock +import kotlin.time.Instant +import kotlin.collections.plus +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime + +/** + * Manages the routines fetching the btc exchange rates. The frontend app must add fiat currencies they + * wish to observe to the [monitoredCurrencies], and the manager will handle the rest. + * + * Architecture Notes: + * + * At the time of implementation, it was determined that the bitcoin markets for both USD & EUR + * were sufficiently deep & liquid enough to provide reliable rates. + * + * That is to say, if you fetch the BTC-USD rate, and the BTC-EUR rate, + * you would then be able to reliably approximate the USD-EUR rate. + * I.e. calculated rate would reliably approximate official USD-EUR mid-market rate. + * + * However, the same is not true for every fiat currency. + * For example, fetching the BTC-COP rate produces an unreliable approximate for USD-COP. + * + * It is expected that this will improve over time as the markets mature. + * However, for the time being, we rely on the more liquid USD-FIAT exchange rates. + * Thus, if we fetch both BTC-USD & USD-COP, we can easily convert between any of the 3 currencies. + */ +@OptIn(ExperimentalTime::class) +class CurrencyManager( + loggerFactory: LoggerFactory, + val appDb: SqliteAppDb, +) { + val log = loggerFactory.newLogger(this::class) + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val blockchainInfoAPI = BlockchainInfoApi(loggerFactory) + private val yadioAPI = YadioAPI(loggerFactory) + private val coinbaseAPI = CoinbaseAPI(loggerFactory) + private val bluelyticsAPI = BluelyticsAPI(loggerFactory) + + /** Public consumable flow that includes the most recent exchange rates */ + val ratesFlow: StateFlow> by lazy { + appDb.listBitcoinRates().stateIn( + scope = scope, + started = SharingStarted.Companion.Eagerly, + initialValue = listOf() + ) + } + + private var refreshList = mutableMapOf() + private var autoRefreshJob: Job? = null + + private val _refreshFlow = MutableStateFlow>(setOf()) + val refreshFlow: StateFlow> = _refreshFlow + + private var networkAccessEnabled = false + private var autoRefreshEnabled = true + + // monitored currencies are grouped by wallet id to easily add/remove currencies from the flow when starting/shutting down wallets + private val _monitoredCurrencies by lazy { MutableStateFlow>>(emptyMap()) } + + // always monitor USD, because it's the basis btc rate used by most currencies + @OptIn(ExperimentalCoroutinesApi::class) + val monitoredCurrencies = _monitoredCurrencies.mapLatest { + (it.values.flatten().toSet() + FiatCurrency.USD).also { log.debug { "monitoring $it" } } + }.stateIn(scope = scope, started = SharingStarted.Lazily, initialValue = setOf(FiatCurrency.USD)) + + /** Wallet id is the hash160 of the wallet's node id */ + fun startMonitoringCurrencies(walletId: String, currencies: PreferredFiatCurrencies) { + _monitoredCurrencies.value += walletId to currencies.all + } + + fun stopMonitoringForWallet(walletId: String) { + _monitoredCurrencies.value -= walletId + } + + /** Called by AppConnectionsDaemon when internet is available. */ + internal fun enableNetworkAccess() { + networkAccessEnabled = true + maybeStartAutoRefresh() + } + + /** Called by AppConnectionsDaemon when no connection is available. */ + internal fun disableNetworkAccess() { + networkAccessEnabled = false + stopAutoRefresh() + } + + fun enableAutoRefresh() { + autoRefreshEnabled = true + maybeStartAutoRefresh() + } + + fun disableAutoRefresh() { + autoRefreshEnabled = false + stopAutoRefresh() + } + + private fun maybeStartAutoRefresh() { + if (networkAccessEnabled && autoRefreshEnabled && autoRefreshJob == null) { + autoRefreshJob = launchAutoRefreshJob() + } + } + + private fun stopAutoRefresh() = scope.launch { + autoRefreshJob?.cancelAndJoin() + autoRefreshJob = null + } + + // only used by iOS + fun refreshAll(targets: List, force: Boolean = true) = scope.launch { + stopAutoRefresh().join() + val targetSet = targets.toSet() + FiatCurrency.USD + + val deferred1 = async { + refresh(targetSet, blockchainInfoAPI, forceRefresh = force) + } + val deferred2 = async { + refresh(targetSet, coinbaseAPI, forceRefresh = force) + } + val deferred3 = async { + refresh(targetSet, bluelyticsAPI, forceRefresh = force) + } + val deferred4 = async { + refresh(targetSet, yadioAPI, forceRefresh = force) + } + listOf(deferred1, deferred2, deferred3, deferred4).awaitAll() + maybeStartAutoRefresh() + } + + private fun launchAutoRefreshJob() = scope.launch { + var blockchainInfoJob: Job? = null + var coinbaseJob: Job? = null + var bluelyticsJob: Job? = null + var yadioJob: Job? = null + + monitoredCurrencies.collect { currencies -> + blockchainInfoJob?.cancel() + blockchainInfoJob = launchAutoRefreshJob(currencies, blockchainInfoAPI) + + coinbaseJob?.cancel() + coinbaseJob = launchAutoRefreshJob(currencies, coinbaseAPI) + + bluelyticsJob?.cancel() + bluelyticsJob = launchAutoRefreshJob(currencies, bluelyticsAPI) + + yadioJob?.cancel() + yadioJob = launchAutoRefreshJob(currencies, yadioAPI) + } + } + + private fun launchAutoRefreshJob(allTargets: Set, api: ExchangeRateApi) = scope.launch { + val targets = allTargets.filter { api.fiatCurrencies.contains(it) }.toSet() + if (targets.isEmpty()) { + log.debug { "API(${api.name}): Nothing to refresh" } + return@launch + } + + while (isActive) { + val nextDelay = calculateDelay(targets, api.refreshDelay) + log.debug { "API(${api.name}): Next refresh: $nextDelay" } + delay(nextDelay) + refresh(targets, api, forceRefresh = false) + } + } + + /** + * Returns a snapshot of the ExchangeRate for the primary FiatCurrency. + * That is, an instance of OriginalFiat, where: + * - type => current primary FiatCurrency (via AppConfigurationManager) + * - price => BitcoinPriceRate.price for FiatCurrency type + */ + fun calculateOriginalFiat(currency: FiatCurrency): ExchangeRate.BitcoinPriceRate? { + val rates = ratesFlow.value + val fiatRate = rates.firstOrNull { it.fiatCurrency == currency } ?: return null + + return when (fiatRate) { + is ExchangeRate.BitcoinPriceRate -> { + // We have a direct exchange rate. + // BitcoinPriceRate.rate => The price of 1 BTC in this currency + fiatRate + } + is ExchangeRate.UsdPriceRate -> { + // We have an indirect exchange rate. + // UsdPriceRate.price => The price of 1 US Dollar in this currency + rates.filterIsInstance().firstOrNull { + it.fiatCurrency == FiatCurrency.USD + }?.let { usdRate -> + ExchangeRate.BitcoinPriceRate( + fiatCurrency = currency, + price = usdRate.price * fiatRate.price, + source = "${fiatRate.source}/${usdRate.source}", + timestampMillis = fiatRate.timestampMillis.coerceAtMost( + usdRate.timestampMillis + ) + ) + } + } + } + } + + /** + * Updates the `refreshList` with fresh RefreshInfo values. + * Only the `attempted` currencies are updated. + * The `refreshed` parameter marks those currencies that were successfully refreshed. + */ + private fun updateRefreshList( + api: ExchangeRateApi, + attempted: Collection, + refreshed: Collection + ) { + val refreshedSet = refreshed.toSet() + val now = Clock.System.now() + attempted.forEach { fiatCurrency -> + if (refreshedSet.contains(fiatCurrency)) { // refresh succeeded + refreshList[fiatCurrency] = RefreshInfo( + lastRefresh = now, + nextRefresh = now + api.refreshDelay, + failCount = 0 + ) + } else { // refresh failed + val refreshInfo = refreshList[fiatCurrency] ?: RefreshInfo() + refreshList[fiatCurrency] = refreshInfo.fail(now) + } + } + } + + private suspend fun calculateDelay( + targets: Set, + refreshDelay: Duration + ): Duration { + + val initialized = targets.all { refreshList.containsKey(it) } + if (!initialized) { + // Initialize the refreshList with the information from the database. + val dbValues = ratesFlow.filterNotNull().first() + .filter { targets.contains(it.fiatCurrency) } + for (fiatCurrency in targets) { + val lastRefresh = dbValues.firstOrNull { it.fiatCurrency == fiatCurrency }?.let { + Instant.Companion.fromEpochMilliseconds(it.timestampMillis) + } ?: run { + Instant.Companion.fromEpochMilliseconds(0) + } + refreshList[fiatCurrency] = RefreshInfo( + lastRefresh = lastRefresh, + nextRefresh = lastRefresh + refreshDelay, + failCount = 0 + ) + } + } + + val nextRefresh = targets.mapNotNull { fiatCurrency -> + refreshList[fiatCurrency] + }.minByOrNull { + it.nextRefresh + }?.nextRefresh + + val now = Clock.System.now() + return if (nextRefresh == null || nextRefresh <= now) { + Duration.Companion.ZERO + } else { + nextRefresh - now + } + } + + /** + * Adds given targets to the publicly visible `refreshFlow`. + * The UI may use this flow to display a progress/spinner to indicate refresh activity. + */ + private fun addRefreshTargets(targets: Set) { + _refreshFlow.update { currentSet -> + currentSet.plus(targets) + } + } + + /** + * Removes the given targets from the publicly visible `refreshFlow`. + * The UI may use this flow to display a progress/spinner to indicate refresh activity. + */ + private fun removeRefreshTargets(targets: Set) { + _refreshFlow.update { currentSet -> + currentSet.minus(targets) + } + } + + /** + * Standard routine to refresh a list of currencies for a given API. + * The given `allTargets` parameter will automatically be filtered, + * and only the necessary currencies will be updated. + */ + private suspend fun refresh( + allTargets: Set, + api: ExchangeRateApi, + forceRefresh: Boolean + ) { + // Filter the `allTargets` set to only include: + // - those in the given api + // - those that actually need to be refreshed (unless forceRefresh is true) + val now = Clock.System.now() + val targets = allTargets.filter { fiatCurrency -> + if (!api.fiatCurrencies.contains(fiatCurrency)) { + false + } else if (forceRefresh) { + true + } else { + // Only include those that need to be refreshed + refreshList[fiatCurrency]?.let { + val result: Boolean = it.nextRefresh <= now // < Android studio bug + result + } ?: true + } + }.toSet() + + if (targets.isEmpty()) { + return + } else { + log.debug { "fetching ${targets.size} exchange rate(s) from ${api.name}" } + addRefreshTargets(targets) + } + + val fetchedRates = api.fetch(targets) + + if (fetchedRates.isNotEmpty()) { + appDb.saveExchangeRates(fetchedRates) + log.debug { "successfully refreshed ${fetchedRates.size} exchange rate(s) from ${api.name}" } + } + + val fetchedCurrencies = fetchedRates.map { it.fiatCurrency }.toSet() + val failedCurrencies = targets.minus(fetchedCurrencies) + if (failedCurrencies.isNotEmpty()) { + log.info { "failed to refresh ${failedCurrencies.size} exchange rate(s) from ${api.name}: ${failedCurrencies.joinToString(",").take(30)}" } + } + + // Update all the corresponding values in `refreshList` + updateRefreshList( + api = api, + attempted = targets, + refreshed = fetchedCurrencies + ) + removeRefreshTargets(targets) + } + + /** Utility class used to track refresh progress on a per-currency basis. */ + private data class RefreshInfo( + val lastRefresh: Instant, + val nextRefresh: Instant, + val failCount: Int + ) { + constructor() : this( + lastRefresh = Instant.Companion.fromEpochMilliseconds(0), + nextRefresh = Instant.Companion.fromEpochMilliseconds(0), + failCount = 0 + ) + + fun fail(now: Instant): RefreshInfo { + val newFailCount = failCount + 1 + val delay = when (newFailCount) { + 1 -> 30.seconds + 2 -> 1.minutes + 3 -> 5.minutes + 4 -> 10.minutes + 5 -> 30.minutes + 6 -> 60.minutes + else -> 120.minutes + } + return RefreshInfo( + lastRefresh = this.lastRefresh, + nextRefresh = now + delay, + failCount = newFailCount + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/FeerateManager.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/FeerateManager.kt new file mode 100644 index 00000000..84daf3fe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/FeerateManager.kt @@ -0,0 +1,111 @@ +/* + * 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.managers.global + +import fr.acinq.bitcoin.Chain +import fr.acinq.lightning.blockchain.fee.FeeratePerByte +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.logging.debug +import fr.acinq.lightning.logging.error +import fr.acinq.lightning.utils.currentTimestampMillis +import fr.acinq.lightning.utils.sat +import fr.acinq.phoenix.data.MempoolFeerate +import fr.acinq.phoenix.managers.NodeParamsManager +import io.ktor.client.HttpClient +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.isSuccess +import io.ktor.serialization.kotlinx.json.json +import io.ktor.utils.io.charsets.Charsets +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long + +/** + * Fetches a feerate estimation from an external provider (mempool.space). + * If no data can be fetched from the service, the wallet should default to the peer-provided funding feerate. + */ +class FeerateManager( + loggerFactory: LoggerFactory, +) { + val log = loggerFactory.newLogger(this::class) + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val jsonFormat = Json { ignoreUnknownKeys = true } + private val httpClient by lazy { + HttpClient { + install(ContentNegotiation) { json(jsonFormat) } + } + } + + private var mempoolFeerateJob: Job? = null + private val _mempoolFeerate by lazy { MutableStateFlow(null) } + val mempoolFeerate by lazy { _mempoolFeerate.asStateFlow() } + + fun stopMonitoringFeerate() { + mempoolFeerateJob?.cancel() + } + + /** Polls an HTTP endpoint every X seconds to get an estimation of the mempool feerate. */ + fun startMonitoringFeerate() { + mempoolFeerateJob = scope.launch { + while (isActive) { + try { + log.debug { "fetching mempool.space feerate" } + // FIXME: use our own endpoint + val response = httpClient.get( + // TODO: after switching to testnet4, consider using the Mainnet endpoint even on Testnet + if (NodeParamsManager.chain is Chain.Mainnet) { + "https://mempool.space/api/v1/fees/recommended" + } else { + "https://mempool.space/testnet/api/v1/fees/recommended" + } + ) + if (response.status.isSuccess()) { + val json = jsonFormat.decodeFromString(response.bodyAsText(Charsets.UTF_8)) + log.debug { "mempool.space feerate endpoint returned json=$json" } + val feerate = MempoolFeerate( + fastest = FeeratePerByte(json["fastestFee"]!!.jsonPrimitive.long.sat), + halfHour = FeeratePerByte(json["halfHourFee"]!!.jsonPrimitive.long.sat), + hour = FeeratePerByte(json["hourFee"]!!.jsonPrimitive.long.sat), + economy = FeeratePerByte(json["economyFee"]!!.jsonPrimitive.long.sat), + minimum = FeeratePerByte(json["minimumFee"]!!.jsonPrimitive.long.sat), + timestamp = currentTimestampMillis(), + ) + _mempoolFeerate.value = feerate + } + } catch (e: Exception) { + log.error { "could not fetch/read data from mempool.space feerate endpoint: ${e.message}" } + } finally { + delay(10 * 60 * 1_000) // pause for 10 min + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/NetworkMonitor.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/NetworkMonitor.kt new file mode 100644 index 00000000..4dbe8854 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/NetworkMonitor.kt @@ -0,0 +1,18 @@ +package fr.acinq.phoenix.managers.global + +import fr.acinq.phoenix.utils.PlatformContext +import kotlinx.coroutines.flow.StateFlow +import fr.acinq.lightning.logging.LoggerFactory + +enum class NetworkState { + Available, + NotAvailable +} + +expect class NetworkMonitor(loggerFactory: LoggerFactory, ctx: PlatformContext) { + val networkState: StateFlow + fun enable() + fun disable() + fun start() + fun stop() +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/WalletContextManager.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/WalletContextManager.kt new file mode 100644 index 00000000..c2caa318 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/WalletContextManager.kt @@ -0,0 +1,190 @@ +/* + * 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.managers.global + +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.logging.debug +import fr.acinq.lightning.logging.error +import fr.acinq.phoenix.data.WalletContext +import fr.acinq.phoenix.data.WalletNotice +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.utils.extensions.phoenixName +import io.ktor.client.HttpClient +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.isSuccess +import io.ktor.serialization.kotlinx.json.json +import io.ktor.utils.io.charsets.Charsets +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.int +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * Manages HTTP calls to the LSP endpoint that fetch contextual information about the wallet or + * the service. For example, check what is the latest available version of Phoenix, or if the + * has an important notice that the user should be aware of. + */ +class WalletContextManager( + loggerFactory: LoggerFactory +) { + val log = loggerFactory.newLogger(this::class) + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val jsonFormat = Json { ignoreUnknownKeys = true } + private val httpClient by lazy { + HttpClient { + install(ContentNegotiation) { json(jsonFormat) } + } + } + + private var walletNoticePollingJob: Job? = null + private var walletContextPollingJob: Job? = null + + /** Wallet notices are short messages provided by the LSP and displayed in the Home screen. */ + private val _walletNotice = MutableStateFlow(null) + val walletNotice = _walletNotice.asStateFlow() + + /** + * The [WalletContext] contains some information about the context in which the wallet operates. A lot of the + * information in the context json was used by the legacy android app, and is now obsolete and ignored. + */ + private val _walletContext = MutableStateFlow(null) + val walletContext = _walletContext.asStateFlow() + + fun startJobs() { + startWalletContextJob() + startWalletNoticeJob() + } + + fun stopJobs() { + walletContextPollingJob?.cancel() + walletNoticePollingJob?.cancel() + } + + /** Starts a coroutine that continuously polls the wallet-context endpoint. The coroutine is tracked in [walletContextPollingJob]. */ + private fun startWalletContextJob() { + walletContextPollingJob = scope.launch { + var pause = 30.seconds + while (isActive) { + pause = (pause * 2).coerceAtMost(10.minutes) + fetchWalletContext()?.let { + _walletContext.value = it + pause = 180.minutes + } + delay(pause) + } + } + } + + /** Fetches and parses the wallet context from the wallet context remote endpoint. Returns null if resource is unavailable or unreadable. */ + private suspend fun fetchWalletContext(): WalletContext? { + return try { + httpClient.get("https://acinq.co/phoenix/walletcontext.json") + } catch (_: Exception) { + try { + httpClient.get("https://s3.eu-west-1.amazonaws.com/acinq.co/phoenix/walletcontext.json") + } catch (e: Exception) { + log.error { "failed to fetch wallet context: ${e.message?.take(200)}" } + null + } + }?.let { response -> + if (response.status.isSuccess()) { + jsonFormat.decodeFromString(response.bodyAsText(Charsets.UTF_8)) + } else { + log.error { "wallet-context returned status=${response.status}" } + null + } + }?.let { json -> + log.debug { "fetched wallet-context=$json" } + try { + val base = json[NodeParamsManager.chain.phoenixName]!! + val isMempoolFull = base.jsonObject["mempool"]?.jsonObject?.get("v1")?.jsonObject?.get("high_usage")?.jsonPrimitive?.booleanOrNull + val androidLatestVersion = base.jsonObject["version"]?.jsonPrimitive?.intOrNull + val androidLatestCriticalVersion = base.jsonObject["latest_critical_version"]?.jsonPrimitive?.intOrNull + WalletContext( + isMempoolFull = isMempoolFull ?: false, + androidLatestVersion = androidLatestVersion ?: 0, + androidLatestCriticalVersion = androidLatestCriticalVersion ?: 0, + ) + } catch (e: Exception) { + log.error { "could not parse wallet-context response: ${e.message}" } + null + } + } + } + + /** Starts a coroutine that continuously polls the wallet-notice endpoint. The coroutine is tracked in [walletNoticePollingJob]. */ + private fun startWalletNoticeJob() { + walletNoticePollingJob = scope.launch { + var pause = 30.seconds + while (isActive) { + pause = (pause * 2).coerceAtMost(10.minutes) + fetchWalletNotice()?.let { + _walletNotice.value = it + pause = 180.minutes + } + delay(pause) + } + } + } + + /** Fetches and parses the wallet context from the wallet context remote endpoint. Returns null if resource is unavailable or unreadable. */ + private suspend fun fetchWalletNotice(): WalletNotice? { + return try { + httpClient.get("https://acinq.co/phoenix/walletnotice.json") + } catch (_: Exception) { + try { + httpClient.get("https://s3.eu-west-1.amazonaws.com/acinq.co/phoenix/walletnotice.json") + } catch (_: Exception) { + null + } + }?.let { response -> + try { + if (response.status.isSuccess()) { + val json = jsonFormat.decodeFromString(response.bodyAsText()) + log.debug { "fetched wallet-notice=$json" } + val notice = json["notice"]!! + val message = notice.jsonObject["message"]!!.jsonPrimitive.content + val index = notice.jsonObject["index"]!!.jsonPrimitive.int + WalletNotice(message = message, index = index) + } else { + null + } + } catch (e: Exception) { + log.debug { "failed to read wallet-notice response: ${e.message}" } + null + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/BlockchainInfoApi.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/BlockchainInfoApi.kt new file mode 100644 index 00000000..ed60836e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/BlockchainInfoApi.kt @@ -0,0 +1,78 @@ +/* + * 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.managers.global.fiatapis + +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.logging.error +import fr.acinq.phoenix.data.BlockchainInfoResponse +import fr.acinq.phoenix.data.ExchangeRate +import fr.acinq.phoenix.data.FiatCurrency +import io.ktor.client.request.get +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes +import kotlin.time.ExperimentalTime + +/** + * The blockchain.info API is used to refresh the BitcoinPriceRates + * for currencies with "high-liquidity markets" (i.e. USD/EUR). + * Since bitcoin prices are volatile, we refresh them often. + */ +@OptIn(ExperimentalTime::class) +class BlockchainInfoApi(loggerFactory: LoggerFactory) : ExchangeRateApi { + + val log = loggerFactory.newLogger(this::class) + + override val name = "blockchain.info" + override val refreshDelay = 20.minutes + override val fiatCurrencies = ExchangeRateApi.highLiquidityMarkets + + override suspend fun fetch(targets: Set): List { + + val httpResponse: HttpResponse? = try { + ExchangeRateApi.httpClient.get(urlString = "https://blockchain.info/ticker") + } catch (e: Exception) { + log.error { "failed to get exchange rates from blockchain.info: ${e.message}" } + null + } + val parsedResponse: BlockchainInfoResponse? = httpResponse?.let { + try { + ExchangeRateApi.json.decodeFromString(it.bodyAsText()) + } catch (e: Exception) { + log.error { "failed to read exchange rates response from blockchain.info: ${e.message}" } + null + } + } + + val timestampMillis = Clock.System.now().toEpochMilliseconds() + val fetchedRates: List = parsedResponse?.let { + targets.mapNotNull { fiatCurrency -> + parsedResponse[fiatCurrency.name]?.let { priceObject -> + ExchangeRate.BitcoinPriceRate( + fiatCurrency = fiatCurrency, + price = priceObject.last, + source = "blockchain.info", + timestampMillis = timestampMillis, + ) + } + } + } ?: listOf() + + return fetchedRates + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/BluelyticsApi.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/BluelyticsApi.kt new file mode 100644 index 00000000..d8c85b53 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/BluelyticsApi.kt @@ -0,0 +1,75 @@ +/* + * 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.managers.global.fiatapis + +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.logging.error +import fr.acinq.phoenix.data.BluelyticsResponse +import fr.acinq.phoenix.data.ExchangeRate +import fr.acinq.phoenix.data.FiatCurrency +import io.ktor.client.request.get +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes +import kotlin.time.ExperimentalTime + +/** + * The bluelytics API is used to fetch the "blue market" price for the Argentine Peso. + * - ARS => government controlled exchange rate + * - ARS_BM => free market exchange rate + */ +class BluelyticsAPI(loggerFactory: LoggerFactory) : ExchangeRateApi { + + val log = loggerFactory.newLogger(this::class) + + override val name = "bluelytics" + override val refreshDelay = 120.minutes + override val fiatCurrencies = setOf(FiatCurrency.ARS_BM) + + @OptIn(ExperimentalTime::class) + override suspend fun fetch(targets: Set): List { + val httpResponse: HttpResponse? = try { + ExchangeRateApi.httpClient.get(urlString = "https://api.bluelytics.com.ar/v2/latest") + } catch (e: Exception) { + log.error { "failed to get exchange rates from api.bluelytics.com.ar: $e" } + null + } + val parsedResponse: BluelyticsResponse? = httpResponse?.let { + try { + ExchangeRateApi.json.decodeFromString(httpResponse.bodyAsText()) + } catch (e: Exception) { + log.error { "failed to get exchange rates response from api.bluelytics.com.ar: $e" } + null + } + } + + val timestampMillis = Clock.System.now().toEpochMilliseconds() + val fetchedRates: List = parsedResponse?.let { + targets.filter { it == FiatCurrency.ARS_BM }.map { + ExchangeRate.UsdPriceRate( + fiatCurrency = FiatCurrency.ARS_BM, + price = parsedResponse.blue.value_avg, + source = "bluelytics.com.ar", + timestampMillis = timestampMillis + ) + } + } ?: listOf() + + return fetchedRates + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/CoinbaseApi.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/CoinbaseApi.kt new file mode 100644 index 00000000..fb2c5a7b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/CoinbaseApi.kt @@ -0,0 +1,81 @@ +/* + * 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.managers.global.fiatapis + +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.logging.error +import fr.acinq.phoenix.data.CoinbaseResponse +import fr.acinq.phoenix.data.ExchangeRate +import fr.acinq.phoenix.data.FiatCurrency +import io.ktor.client.request.get +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes +import kotlin.time.ExperimentalTime + +/** + * The coinbase API is used to refresh select UsdPriceRates. + * Since fiat prices are less volatile, we refresh them less often. + */ +@OptIn(ExperimentalTime::class) +class CoinbaseAPI(loggerFactory: LoggerFactory) : ExchangeRateApi { + + val log = loggerFactory.newLogger(this::class) + + override val name = "coinbase" + override val refreshDelay = 60.minutes + override val fiatCurrencies = FiatCurrency.Companion.values.filter { + // bascially, everything except USD, EURO, and special markets + !ExchangeRateApi.highLiquidityMarkets.contains(it) && !ExchangeRateApi.specialMarkets.contains(it) && !ExchangeRateApi.missingFromCoinbase.contains(it) + }.toSet() + + override suspend fun fetch(targets: Set): List { + val httpResponse: HttpResponse? = try { + ExchangeRateApi.httpClient.get(urlString = "https://api.coinbase.com/v2/exchange-rates?currency=USD") + } catch (e: Exception) { + log.error { "failed to get exchange rates from api.coinbase.com: $e" } + null + } + val parsedResponse: CoinbaseResponse? = httpResponse?.let { + try { + ExchangeRateApi.json.decodeFromString(it.bodyAsText()) + } catch (e: Exception) { + log.error { "failed to get exchange rates response from api.coinbase.com: $e" } + null + } + } + + val timestampMillis = Clock.System.now().toEpochMilliseconds() + val fetchedRates: List = parsedResponse?.let { + targets.mapNotNull { fiatCurrency -> + parsedResponse.data.rates[fiatCurrency.name]?.let { valueAsString -> + valueAsString.toDoubleOrNull()?.let { valueAsDouble -> + ExchangeRate.UsdPriceRate( + fiatCurrency = fiatCurrency, + price = valueAsDouble, + source = "coinbase.com", + timestampMillis = timestampMillis + ) + } + } + } + } ?: listOf() + + return fetchedRates + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/ExchangeRateApi.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/ExchangeRateApi.kt new file mode 100644 index 00000000..1318ecf2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/ExchangeRateApi.kt @@ -0,0 +1,83 @@ +/* + * 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.managers.global.fiatapis + +import fr.acinq.phoenix.data.ExchangeRate +import fr.acinq.phoenix.data.FiatCurrency +import io.ktor.client.HttpClient +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json +import kotlin.time.Duration + + +/** + * We use a number of different APIs to fetch all the data we need. + * This interface defines the shared format for each API. + */ +interface ExchangeRateApi { + /** Primarily used for debugging */ + val name: String + + /** + * How often to perform an automatic refresh. + * Some APIs impose limits, and others simply don't refresh (server-side) as often. + */ + val refreshDelay: Duration + + /** + * List of fiat currencies updated by the API. + * A currency should only be represented in a single API. + */ + val fiatCurrencies: Set + + suspend fun fetch(targets: Set): List + + companion object { + val json = Json { ignoreUnknownKeys = true } + + val httpClient by lazy { + HttpClient { + install(ContentNegotiation) { + json(json) + } + } + } + + /** + * List of fiat currencies where we directly fetch the FIAT/BTC exchange rate. + * See "architecture notes" at top of file for discussion. + */ + val highLiquidityMarkets = setOf(FiatCurrency.USD, FiatCurrency.EUR) + + val specialMarkets = setOf( + FiatCurrency.ARS_BM, // Argentine Peso (blue market) + FiatCurrency.CUP_FM, // Cuban Peso (free market) + FiatCurrency.LBP_BM // Lebanese Pound (black market) + ) + + val missingFromCoinbase = setOf( + FiatCurrency.CUP, // Cuban Peso + FiatCurrency.ERN, // Eritrean Nakfa (exists in response, but refers to ERN altcoin) + FiatCurrency.IRR, // Iranian Rial + FiatCurrency.KPW, // North Korean Won + FiatCurrency.SDG, // Sudanese Pound + FiatCurrency.SOS, // Somali Shilling + FiatCurrency.SYP // Syrian Pound + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/YadioApi.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/YadioApi.kt new file mode 100644 index 00000000..96bf235e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/YadioApi.kt @@ -0,0 +1,79 @@ +/* + * 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.managers.global.fiatapis + +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.logging.error +import fr.acinq.phoenix.data.ExchangeRate +import fr.acinq.phoenix.data.FiatCurrency +import fr.acinq.phoenix.data.YadioResponse +import io.ktor.client.request.get +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes +import kotlin.time.ExperimentalTime + +/** + * The yadio API is used to fetch various "free market" prices. + * For example: + * - CUP => government controlled exchange rate + * - CUP_FM => free market exchange rate + */ +@OptIn(ExperimentalTime::class) +class YadioAPI(loggerFactory: LoggerFactory) : ExchangeRateApi { + + val log = loggerFactory.newLogger(this::class) + + override val name = "yadio" + override val refreshDelay = 120.minutes + override val fiatCurrencies = setOf(FiatCurrency.CUP_FM, FiatCurrency.LBP_BM) + + override suspend fun fetch(targets: Set): List { + val httpResponse: HttpResponse? = try { + ExchangeRateApi.httpClient.get(urlString = "https://api.yadio.io/exrates/USD") + } catch (e: Exception) { + log.error { "failed to get exchange rates from api.yadio.io: $e" } + null + } + val parsedResponse: YadioResponse? = httpResponse?.let { + try { + ExchangeRateApi.json.decodeFromString(httpResponse.bodyAsText()) + } catch (e: Exception) { + log.error { "failed to get exchange rates response from api.yadio.io: $e" } + null + } + } + + val timestampMillis = Clock.System.now().toEpochMilliseconds() + val fetchedRates: List = parsedResponse?.let { + targets.mapNotNull { fiatCurrency -> + val name = fiatCurrency.name.take(3) + parsedResponse.usdRates[name]?.let { valueAsDouble -> + ExchangeRate.UsdPriceRate( + fiatCurrency = fiatCurrency, + price = valueAsDouble, + source = "yadio.io", + timestampMillis = timestampMillis + ) + } + } + } ?: listOf() + + return fetchedRates + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedData.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedData.kt new file mode 100644 index 00000000..1085b10b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedData.kt @@ -0,0 +1,131 @@ +/* + * 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.security + +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.Crypto +import fr.acinq.bitcoin.byteVector +import fr.acinq.lightning.crypto.ChaCha20Poly1305 +import fr.acinq.lightning.crypto.LocalKeyManager +import fr.acinq.phoenix.managers.cloudKey +import no.synth.kmpzip.okio.asInputStream +import no.synth.kmpzip.okio.asOutputStream +import no.synth.kmpzip.zip.ZipEntry +import no.synth.kmpzip.zip.ZipInputStream +import no.synth.kmpzip.zip.ZipOutputStream +import okio.Buffer + +/** + * This object represents data encrypted with a key derived from the wallet seed. The type of encryption and key used depends on [version]. + * + * Note that the data is zipped before encryption. + * + * @param version the version used for the data encryption and for serializing this object + * @param data a byte array containing the zipped & encrypted payload, followed by the stuff needed for decryption (which depends on the version) + */ +class EncryptedData(val version: Version, val data: ByteArray) { + + sealed class Version(val code: Byte) { + // use ChaCha20Poly1305, inspired from the channel backup encryption code in lightning-kmp + // see https://github.com/ACINQ/lightning-kmp/blob/feda82c853660a792b911be518367a228ed6e0ee/modules/core/src/commonMain/kotlin/fr/acinq/lightning/serialization/channel/Encryption.kt#L14 + data object V1 : Version(1) { + fun getKey(keyManager: LocalKeyManager) = keyManager.cloudKey().toByteArray() + } + } + + /** Decrypts the [data] payload. The data is unzipped after being decrypted. */ + fun decrypt(keyManager: LocalKeyManager): ByteVector { + val decryptedPayload = when (val v = version) { + is Version.V1 -> { + val key = v.getKey(keyManager) + // nonce is 12B, tag is 16B + val ciphertext = data.dropLast(12 + 16).toByteArray() + val nonce = data.takeLast(12 + 16).take(12).toByteArray() + val tag = data.takeLast(16).toByteArray() + ChaCha20Poly1305.decrypt(key, nonce, ciphertext, ByteArray(0), tag) + } + } + + val unzipped = Buffer().write(decryptedPayload).asInputStream().use { bis -> + ZipInputStream(bis).use { zis -> + zis.nextEntry + zis.readBytes() + } + } + + return unzipped.byteVector() + } + + /** Serialize this object into a byte array, so that it can be for example written to a file. */ + fun write(): ByteArray { + return when (version) { + Version.V1 -> { + val bos = Buffer() + bos.writeByte(version.code.toInt()) + bos.write(data) + bos.readByteArray() + } + } + } + + companion object { + + /** + * Encrypts [data] using a key from the wallet's [keyManager]. The key used depends on [version]. The data is zipped before being encrypted. + * + * @param data the unencrypted data + * @returns an [EncryptedData] object, containing the zipped & encrypted payload + */ + fun encrypt(version: Version, data: ByteArray, keyManager: LocalKeyManager): EncryptedData { + + val payload = Buffer().run { + ZipOutputStream(this.asOutputStream()).use { zos -> + zos.putNextEntry(ZipEntry("data")) + zos.write(data) + } + readByteArray() + } + + return when (version) { + is Version.V1 -> { + val key = version.getKey(keyManager) + val nonce = Crypto.sha256(payload).take(12).toByteArray() + val (ciphertext, tag) = ChaCha20Poly1305.encrypt(key, nonce, payload, ByteArray(0)) + EncryptedData(version, data = ciphertext + nonce + tag) + } + } + } + + /** Deserializes the blob data into an [EncryptedData] object. Throws an exception if the version byte is invalid. */ + fun read(data: ByteArray): EncryptedData { + return Buffer().write(data).asInputStream().use { bis -> + when (val version = bis.read().toByte()) { + Version.V1.code -> { + val remainingBytes = bis.available() + val payload = ByteArray(remainingBytes) + bis.read(payload, 0, remainingBytes) + EncryptedData(version = Version.V1, data = payload) + } + + else -> { + throw RuntimeException("unhandled version=$version") + } + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedPin.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedPin.kt new file mode 100644 index 00000000..fddbeaaf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedPin.kt @@ -0,0 +1,59 @@ +/* + * 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.security + +import okio.Buffer +import okio.BufferedSource + +abstract class EncryptedPin { + abstract val name: String + override fun toString(): String = name + + /** + * Data is encrypted with a key from the Android Keystore. The keystore's key does not require user authentication to be used. + * The serialized content contains the version, the IV, and the payload. + */ + abstract class KeystoreEncrypted : EncryptedPin() { + abstract val iv: ByteArray + abstract val ciphertext: ByteArray + + fun decrypt(): ByteArray = keyStoreDecryption(KeyStoreNames.KEY_FOR_PINCODE_V1, iv, ciphertext) + + fun serialize(version: Int): ByteArray { + if (iv.size != IV_LENGTH) { + throw RuntimeException("cannot serialize $name: iv not of the correct length (${iv.size}/$IV_LENGTH)") + } + val array = Buffer() + array.writeByte(version) + array.write(iv) + array.write(ciphertext) + return array.readByteArray() + } + + companion object Companion { + const val IV_LENGTH = 16 + fun deserialize(stream: BufferedSource): Pair { + val iv = ByteArray(IV_LENGTH) + stream.read(iv, 0, IV_LENGTH) + val availableBytes = stream.buffer.size.toInt() + val cipherText = ByteArray(availableBytes) + stream.read(cipherText, 0, availableBytes) + return iv to cipherText + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedPinLock.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedPinLock.kt new file mode 100644 index 00000000..eecd0bcd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedPinLock.kt @@ -0,0 +1,73 @@ +/* + * 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.security + +import fr.acinq.phoenix.data.WalletId +import kotlinx.serialization.json.Json +import okio.Buffer + + +sealed class EncryptedPinLock: EncryptedPin.KeystoreEncrypted() { + + class SingleWallet(override val iv: ByteArray, override val ciphertext: ByteArray) : EncryptedPinLock() { + override val name: String = "ENCRYPTED_LOCK_PIN_SINGLEWALLET" + } + + class MultipleWallet(override val iv: ByteArray, override val ciphertext: ByteArray) : EncryptedPinLock() { + override val name: String = "ENCRYPTED_LOCK_PIN_MULTIWALLET" + + fun decryptAndGetPins(): Map { + val payload = decrypt().decodeToString() + val json = Json.decodeFromString>(payload) + return json.map { WalletId(it.key) to it.value }.toMap() + } + } + + companion object { + const val SINGLE_WALLET_VERSION: Byte = 1 + const val MULTIPLE_WALLET_VERSION: Byte = 2 + + fun encrypt(pinMap: Map): MultipleWallet { + val json = Json.encodeToString( + pinMap.map { (id, pin) -> id.nodeIdHash to pin }.toMap() + ) + + return keyStoreEncryption(KeyStoreNames.KEY_FOR_PINCODE_V1, json.encodeToByteArray()).let { cipherText -> + MultipleWallet( + cipherText.first, + cipherText.second + ) + } + } + + fun deserialize(serialized: ByteArray): EncryptedPinLock { + val stream = Buffer().write(serialized) + val version = stream.readByte() + when (version) { + SINGLE_WALLET_VERSION, MULTIPLE_WALLET_VERSION -> { + val (iv, ciphertext) = deserialize(stream) + return when (version) { + SINGLE_WALLET_VERSION -> SingleWallet(iv, ciphertext) + MULTIPLE_WALLET_VERSION -> MultipleWallet(iv, ciphertext) + else -> throw IllegalArgumentException("unhandled V1 lock-pin version=$version") + } + } + else -> throw IllegalArgumentException("unhandled lock-pin file version=$version") + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedPinSpending.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedPinSpending.kt new file mode 100644 index 00000000..ddebce87 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedPinSpending.kt @@ -0,0 +1,79 @@ +/* + * 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.security + +import fr.acinq.phoenix.data.WalletId +import kotlinx.serialization.json.Json +import okio.Buffer + + +/** + * This object represents an encrypted PIN data. + * + * Similar to the [EncryptedSeed] data: it contains a version, IV, and the encrypted payload and uses a key + * from the Android Keystore to encrypt the PIN. + */ +sealed class EncryptedPinSpending: EncryptedPin.KeystoreEncrypted() { + + class SingleWallet(override val iv: ByteArray, override val ciphertext: ByteArray) : EncryptedPinSpending() { + override val name: String = "ENCRYPTED_SPENDING_PIN_SINGLEWALLET" + } + + class MultipleWallet(override val iv: ByteArray, override val ciphertext: ByteArray) : EncryptedPinSpending() { + override val name: String = "ENCRYPTED_SPENDING_PIN_MULTIWALLET" + + fun decryptAndGetPins(): Map { + val payload = decrypt().decodeToString() + val json = Json.decodeFromString>(payload) + return json.map { WalletId(it.key) to it.value }.toMap() + } + } + + companion object { + const val SINGLE_WALLET_VERSION: Byte = 1 + const val MULTIPLE_WALLET_VERSION: Byte = 2 + + fun encrypt(pinMap: Map): MultipleWallet { + val json = Json.encodeToString( + pinMap.map { (id, pin) -> id.nodeIdHash to pin }.toMap() + ) + + return keyStoreEncryption(KeyStoreNames.KEY_FOR_PINCODE_V1, json.encodeToByteArray()).let { cipherText -> + MultipleWallet( + cipherText.first, + cipherText.second + ) + } + } + + fun deserialize(serialized: ByteArray): EncryptedPinSpending { + val stream = Buffer().write(serialized) + val version = stream.readByte() + when (version) { + SINGLE_WALLET_VERSION, MULTIPLE_WALLET_VERSION -> { + val (iv, ciphertext) = deserialize(stream) + return when (version) { + SINGLE_WALLET_VERSION -> SingleWallet(iv, ciphertext) + MULTIPLE_WALLET_VERSION -> MultipleWallet(iv, ciphertext) + else -> throw IllegalArgumentException("unhandled V1 spending-pin version=$version") + } + } + else -> throw IllegalArgumentException("unhandled spending-pin file version=$version") + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedSeed.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedSeed.kt new file mode 100644 index 00000000..93a39285 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedSeed.kt @@ -0,0 +1,133 @@ +/* + * 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.security + +import co.touchlab.kermit.Logger +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.utils.MnemonicLanguage +import fr.acinq.secp256k1.Hex +import kotlinx.serialization.json.Json +import okio.Buffer +import okio.BufferedSource + +sealed class EncryptedSeed { + + /** Serializes an encrypted seed as a byte array. */ + abstract fun serialize(): ByteArray + + override fun toString(): String = this::class.qualifiedName ?: this::class.simpleName ?: "EncryptedSeed" + + /** + * [V2] encrypts the seed with a SecretKey from the Keystore. The key in the Keystore does not require user authentication. + * The content may be for a [SingleSeed] or a [MultipleSeed]. + */ + sealed class V2 : EncryptedSeed() { + abstract val iv: ByteArray + abstract val ciphertext: ByteArray + + @Deprecated("Obsolete, do not use. Instead use [MultipleSeed] that supports multiple wallets.") + class SingleSeed(override val iv: ByteArray, override val ciphertext: ByteArray) : V2() { + companion object { + /** Returns a mnemonics from a byte array, or null if it's invalid. */ + fun toMnemonicsSafe(array: ByteArray): List? { + return try { + val mnemonics = Hex.decode(array.decodeToString()).decodeToString().split(" ") + MnemonicCode.validate(mnemonics = mnemonics, wordlist = MnemonicLanguage.English.wordlist()) + mnemonics + } catch (e: Exception) { + log.e("seed is invalid", e) + null + } + } + } + } + + class MultipleSeed(override val iv: ByteArray, override val ciphertext: ByteArray) : V2() { + fun decryptAndGetSeedMap(): Map> { + val payload = decrypt().decodeToString() + val json = Json.decodeFromString>>(payload) + return json.map { WalletId(it.key) to it.value }.toMap() + } + } + + fun decrypt(): ByteArray = keyStoreDecryption(KeyStoreNames.KEY_NO_AUTH, iv, ciphertext) + + @Suppress("DEPRECATION") + /** Serialize to a V2 ByteArray. */ + override fun serialize(): ByteArray { + if (iv.size != IV_LENGTH) { + throw RuntimeException("cannot serialize seed: iv not of the correct length") + } + val array = Buffer() + array.writeByte(SEED_FILE_VERSION_2.toInt()) + array.writeByte( + when (this) { + is SingleSeed -> SINGLE_SEED_VERSION.toInt() + is MultipleSeed -> MULTIPLE_SEED_VERSION.toInt() + } + ) + array.write(iv) + array.write(ciphertext) + return array.readByteArray() + } + + companion object { + private const val IV_LENGTH = 16 + private const val SINGLE_SEED_VERSION: Byte = 1 + // version=2 has been used and removed, do not use again. + private const val MULTIPLE_SEED_VERSION: Byte = 3 + + fun deserialize(stream: BufferedSource): V2 { + val version = stream.readByte() + val iv: ByteArray = ByteArray(IV_LENGTH) + stream.read(iv, 0, IV_LENGTH) + val remainingBytes = stream.buffer.size.toInt() + val cipherText: ByteArray = ByteArray(remainingBytes) + stream.read(cipherText, 0, remainingBytes) + return when (version) { + SINGLE_SEED_VERSION -> SingleSeed(iv, cipherText) + MULTIPLE_SEED_VERSION -> MultipleSeed(iv, cipherText) + else -> throw IllegalArgumentException("unhandled V2 seed version=$version") + } + } + + fun encrypt(seedMap: Map>): MultipleSeed { + val json = Json.encodeToString( + seedMap.map { (id, words) -> id.nodeIdHash to words }.toMap() + ) + val ciphertext = keyStoreEncryption(KeyStoreNames.KEY_NO_AUTH, json.encodeToByteArray()) + return MultipleSeed(ciphertext.first, ciphertext.second) + } + } + } + + companion object { + val log = Logger.withTag("EncryptedSeed") + + const val SEED_FILE_VERSION_2: Byte = 2 + + /** Reads an array of byte and de-serializes it as an [EncryptedSeed] object. */ + fun deserialize(serialized: ByteArray): EncryptedSeed { + val stream = Buffer().write(serialized) + return when (val version = stream.readByte()) { + SEED_FILE_VERSION_2 -> V2.deserialize(stream) + else -> throw IllegalArgumentException("unhandled seed file version=$version") + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/KeyStoreFunctions.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/KeyStoreFunctions.kt new file mode 100644 index 00000000..e6a75d66 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/KeyStoreFunctions.kt @@ -0,0 +1,5 @@ +package fr.acinq.phoenix.security + +expect fun keyStoreDecryption(keyName: String, iv: ByteArray, ciphertext: ByteArray): ByteArray + +expect fun keyStoreEncryption(keyName: String, plainText: ByteArray): Pair \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/KeyStoreNames.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/KeyStoreNames.kt new file mode 100644 index 00000000..8e8e786f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/KeyStoreNames.kt @@ -0,0 +1,9 @@ +package fr.acinq.phoenix.security + +object KeyStoreNames { + /** The alias of the key used to encrypt an [EncryptedSeed.V2] seed. */ + const val KEY_NO_AUTH = "PHOENIX_KEY_NO_AUTH" + + /** The alias of the key used to encrypt [EncryptedLockPin.KeystoreEncrypted], [EncryptedPinLock.V2], [EncryptedPinSpending.V1], [EncryptedPinSpending.V2]. */ + const val KEY_FOR_PINCODE_V1 = "PHOENIX_KEY_FOR_PINCODE_V1" +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/BlockchainExplorer.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/BlockchainExplorer.kt new file mode 100644 index 00000000..78dcbf06 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/BlockchainExplorer.kt @@ -0,0 +1,59 @@ +package fr.acinq.phoenix.utils + +import fr.acinq.bitcoin.Chain +import fr.acinq.bitcoin.TxId + + +class BlockchainExplorer(private val chain: Chain) { + + sealed class Website(val base: String) { + object MempoolSpace: Website("https://mempool.space") + object BlockstreamInfo: Website("https://blockstream.info") + } + + fun txUrl(txId: TxId, website: Website = Website.MempoolSpace): String { + return when (website) { + Website.MempoolSpace -> { + when (chain) { + Chain.Mainnet -> "${website.base}/tx/$txId" + Chain.Testnet3 -> "${website.base}/testnet/tx/$txId" + Chain.Testnet4 -> "${website.base}/testnet4/tx/$txId" + Chain.Signet -> "${website.base}/signet/tx/$txId" + Chain.Regtest -> "${website.base}/_REGTEST_/tx/$txId" + } + } + Website.BlockstreamInfo -> { + when (chain) { + Chain.Mainnet -> "${website.base}/tx/$txId" + Chain.Testnet3 -> "${website.base}/testnet/tx/$txId" + Chain.Testnet4 -> "${website.base}/testnet4/tx/$txId" + Chain.Signet -> "${website.base}/signet/tx/$txId" + Chain.Regtest -> "${website.base}/_REGTEST_/tx/$txId" + } + } + } + } + + fun addressUrl(addr: String, website: Website = Website.MempoolSpace): String { + return when (website) { + Website.MempoolSpace -> { + when (chain) { + Chain.Mainnet -> "${website.base}/address/$addr" + Chain.Testnet3 -> "${website.base}/testnet/address/$addr" + Chain.Testnet4 -> "${website.base}/testnet4/address/$addr" + Chain.Signet -> "${website.base}/signet/address/$addr" + Chain.Regtest -> "${website.base}/_REGTEST_/address/$addr" + } + } + Website.BlockstreamInfo -> { + when (chain) { + Chain.Mainnet -> "${website.base}/address/$addr" + Chain.Testnet3 -> "${website.base}/testnet/address/$addr" + Chain.Testnet4 -> "${website.base}/testnet4/address/$addr" + Chain.Signet -> "${website.base}/signet/address/$addr" + Chain.Regtest -> "${website.base}/_REGTEST_/address/$addr" + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/Cache.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/Cache.kt new file mode 100644 index 00000000..89d7a2ce --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/Cache.kt @@ -0,0 +1,248 @@ +package fr.acinq.phoenix.utils + +/** + * The Cache implements a simple strict cache. + * + * It monitors usage (both gets & puts) so that eviction is properly ordered. + * + * For example: + * If you set a sizeLimit of 4, then when you add the 5th item to the cache, + * another item is automatically evicted. + * + * Which item gets evicted depends entirely on usage. + * The Cache maintains a doubly linked-list of items ordered by access. + * The most recently accessed item is at the front of the linked-list, + * and the least recently accessed item is at the back. + * So it's very quick and efficient to evict items based on recent usage. + * It's also efficient to update the linked-list during usage. + */ +class Cache(sizeLimit: Int) { + + private class CacheItem( + var key: Key, + var value: Value + ) { + var prev: CacheItem? = null // linked-list pointer + var next: CacheItem? = null // linked-list pointer + } + + private var _sizeLimit: Int + private var _map: MutableMap> + + init { + _sizeLimit = sizeLimit + _map = mutableMapOf() + } + + // Pointers to front & back of linked-list + private var mostRecentCacheItem: CacheItem? = null + private var leastRecentCacheItem: CacheItem? = null + + val size: Int get() = _map.size + + var sizeLimit: Int + get() = _sizeLimit + set(newValue) { + + if (_sizeLimit == newValue) { + return // no changes + } + + _sizeLimit = newValue + if (_sizeLimit > 0) { + + while (_map.size > _sizeLimit) { + + // To get to this code branch: + // - _sizeLimit > 0 + // - _map.size > _sizeLimit + // + // Thus: _map.size > 1, meaning: + // - leastRecentCacheItem is non-null + // - leastRecentCacheItem.prev is non-null + + val keyToEvict = leastRecentCacheItem!!.key + + leastRecentCacheItem = leastRecentCacheItem?.prev + leastRecentCacheItem?.next = null + + _map.remove(keyToEvict) + } + } + } // + + fun isUnlimited(): Boolean = _sizeLimit <= 0 + fun isEmpty(): Boolean = _map.isEmpty() + fun containsKey(key: Key): Boolean = _map.containsKey(key) + + operator fun get(key: Key): Value? { + + return _map[key]?.let { item -> + + if (item !== mostRecentCacheItem) { + + // Remove item from current position in linked-list. + // + // Since item is non-null, + // we know there's a valid mostRecentCacheItem & leastRecentCacheItem. + + item.prev?.next = item.next + + if (item === leastRecentCacheItem) { + // We know the item is NOT the mostRecentCacheItem. + // We know the item IS the leastRecentCacheItem. + // Thus: there are at least 2 items in the list + + leastRecentCacheItem = item.prev + + } else { + // We know the item is NOT the mostRecentCacheItem. + // We know the item is NOT the leastRecentCacheItem. + // Thus: there are at least 3 items in the list + + item.next?.prev = item.prev + } + + // Move item to beginning of linked-list + + item.prev = null + item.next = mostRecentCacheItem + + mostRecentCacheItem?.prev = item + mostRecentCacheItem = item + } + + item.value + } + } + + operator fun set(key: Key, value: Value): Unit { + + val existingItem = _map[key] + if (existingItem != null) { + + // Update item value + existingItem.value = value + + if (existingItem !== mostRecentCacheItem) { + + // Remove item from current position in linked-list + // + // Notes: + // We fetched the item from the list, + // so we know there's a valid mostRecentCacheItem & leastRecentCacheItem. + // Furthermore, we know the item isn't the mostRecentCacheItem. + + existingItem.prev?.next = existingItem.next + + if (existingItem === leastRecentCacheItem) { + // We know the item is NOT the mostRecentCacheItem. + // We know the item IS the leastRecentCacheItem. + // Thus: there are at least 2 items in the list + + leastRecentCacheItem = existingItem.prev + } else { + // We know the item is NOT the mostRecentCacheItem. + // We know the item is NOT the leastRecentCacheItem. + // Thus: there are at least 3 items in the list + + existingItem.next?.prev = existingItem.prev + } + + // Move item to beginning of linked-list + + existingItem.prev = null + existingItem.next = mostRecentCacheItem + + mostRecentCacheItem?.prev = existingItem + mostRecentCacheItem = existingItem + } + + } else { // existingItem == null + + val newItem = CacheItem(key, value) + _map[key] = newItem + + // Add item to beginning of linked-list + + newItem.next = mostRecentCacheItem + + mostRecentCacheItem?.prev = newItem + mostRecentCacheItem = newItem + + // Evict leastRecentCacheItem if needed + + if ((_sizeLimit > 0) && (_map.size > _sizeLimit)) { + + val keyToEvict = leastRecentCacheItem!!.key + + leastRecentCacheItem = leastRecentCacheItem?.prev + leastRecentCacheItem?.next = null + + _map.remove(keyToEvict) + + } else { + + if (leastRecentCacheItem == null) { + // There is only 1 item in list. + // leastRecentCacheItem === mostRecentCacheItem === newItem + leastRecentCacheItem = newItem + } + } + } + } + + fun remove(key: Key): Unit { + + _map[key]?.let { item -> + + if (mostRecentCacheItem === item) { + mostRecentCacheItem = item.next + } else { + item.prev?.next = item.next + } + + if (leastRecentCacheItem === item) { + leastRecentCacheItem = item.prev + } else { + item.next?.prev = item.prev + } + + _map.remove(key) + } + } + + fun clear(): Unit { + + var item = leastRecentCacheItem + while (item != null) { + + val prev = item.prev + item.next = null + item.prev = null + item = prev + } + + leastRecentCacheItem = null + mostRecentCacheItem = null + _map.clear() + } + + fun filteredKeys( + isIncluded: (Key) -> Boolean + ): List { + + var results = mutableListOf() + + var item = mostRecentCacheItem + while (item != null) { + if (isIncluded(item.key)) { + results.add(item.key) + } + + item = item.next + } + + return results + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/DateUtils.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/DateUtils.kt new file mode 100644 index 00000000..754c6ef5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/DateUtils.kt @@ -0,0 +1,18 @@ +package fr.acinq.phoenix.utils + +class DateUtils { + + companion object { + const val SECOND_IN_MILLIS: Long = 1000 + + const val MINUTE_IN_MILLIS: Long = SECOND_IN_MILLIS * 60 + + const val HOUR_IN_MILLIS: Long = MINUTE_IN_MILLIS * 60 + + const val DAY_IN_MILLIS: Long = HOUR_IN_MILLIS * 24 + + const val WEEK_IN_MILLIS: Long = DAY_IN_MILLIS * 7 + + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/DnsResolvers.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/DnsResolvers.kt new file mode 100644 index 00000000..89dc84a4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/DnsResolvers.kt @@ -0,0 +1,82 @@ +/* + * 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 + +import fr.acinq.lightning.utils.getValue +import io.ktor.client.HttpClient +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpHeaders +import io.ktor.http.headers +import io.ktor.serialization.kotlinx.json.json +import io.ktor.utils.io.charsets.Charsets +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlin.random.Random + +enum class DnsResolvers { + Google { + override fun url(name: String): Pair Unit> = "https://dns.google/resolve?name=$name&type=TXT" to {} + }, +// Cloudflare { +// override fun url(name: String): Pair Unit> = "https://cloudflare-dns.com/dns-query?name=$name&type=TXT" to { +// headers { append(HttpHeaders.Accept, "application/dns-json") } +// } +// }, + ; + + abstract fun url(name: String): Pair Unit> + + suspend fun getTxtRecord(query: String): JsonObject { + val (url, builder) = url(query) + val response = dohClient.get(url, builder) + return Json.decodeFromString(response.bodyAsText(Charsets.UTF_8)) + } + + companion object { + private val dohClient: HttpClient by lazy { + HttpClient { + install(ContentNegotiation) { + json(json = Json { ignoreUnknownKeys = true }) + } + expectSuccess = true + } + } + + fun getRandom(): DnsResolvers { + return Random.nextInt(0, DnsResolvers.entries.size).let { + DnsResolvers.entries[it] + } + } + } +} + +//object DnsHelper { +// + +// +// suspend fun getTXTRecord(query: String): JsonObject { +// Random.nextInt(0, DnsResolvers.entries.size).let { +// DnsResolvers.entries[it] +// }.let { +// val response = dohClient.get("$it?name=$query&type=TXT") +// return Json.decodeFromString(response.bodyAsText(Charsets.UTF_8)) +// } +// } +//} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/MnemonicLanguage.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/MnemonicLanguage.kt new file mode 100644 index 00000000..cfe7a78a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/MnemonicLanguage.kt @@ -0,0 +1,83 @@ +package fr.acinq.phoenix.utils + +import fr.acinq.bitcoin.MnemonicCode + +enum class MnemonicLanguage { + English { + override val code: String get() = "en" + override fun wordlist(): List { + return MnemonicCode.englishWordlist + } + override fun matchMap(): Map { + return mapOf() + } + }, + Spanish { + override val code: String get() = "es" + override fun wordlist(): List { + return "ábaco,abdomen,abeja,abierto,abogado,abono,aborto,abrazo,abrir,abuelo,abuso,acabar,academia,acceso,acción,aceite,acelga,acento,aceptar,ácido,aclarar,acné,acoger,acoso,activo,acto,actriz,actuar,acudir,acuerdo,acusar,adicto,admitir,adoptar,adorno,aduana,adulto,aéreo,afectar,afición,afinar,afirmar,ágil,agitar,agonía,agosto,agotar,agregar,agrio,agua,agudo,águila,aguja,ahogo,ahorro,aire,aislar,ajedrez,ajeno,ajuste,alacrán,alambre,alarma,alba,álbum,alcalde,aldea,alegre,alejar,alerta,aleta,alfiler,alga,algodón,aliado,aliento,alivio,alma,almeja,almíbar,altar,alteza,altivo,alto,altura,alumno,alzar,amable,amante,amapola,amargo,amasar,ámbar,ámbito,ameno,amigo,amistad,amor,amparo,amplio,ancho,anciano,ancla,andar,andén,anemia,ángulo,anillo,ánimo,anís,anotar,antena,antiguo,antojo,anual,anular,anuncio,añadir,añejo,año,apagar,aparato,apetito,apio,aplicar,apodo,aporte,apoyo,aprender,aprobar,apuesta,apuro,arado,araña,arar,árbitro,árbol,arbusto,archivo,arco,arder,ardilla,arduo,área,árido,aries,armonía,arnés,aroma,arpa,arpón,arreglo,arroz,arruga,arte,artista,asa,asado,asalto,ascenso,asegurar,aseo,asesor,asiento,asilo,asistir,asno,asombro,áspero,astilla,astro,astuto,asumir,asunto,atajo,ataque,atar,atento,ateo,ático,atleta,átomo,atraer,atroz,atún,audaz,audio,auge,aula,aumento,ausente,autor,aval,avance,avaro,ave,avellana,avena,avestruz,avión,aviso,ayer,ayuda,ayuno,azafrán,azar,azote,azúcar,azufre,azul,baba,babor,bache,bahía,baile,bajar,balanza,balcón,balde,bambú,banco,banda,baño,barba,barco,barniz,barro,báscula,bastón,basura,batalla,batería,batir,batuta,baúl,bazar,bebé,bebida,bello,besar,beso,bestia,bicho,bien,bingo,blanco,bloque,blusa,boa,bobina,bobo,boca,bocina,boda,bodega,boina,bola,bolero,bolsa,bomba,bondad,bonito,bono,bonsái,borde,borrar,bosque,bote,botín,bóveda,bozal,bravo,brazo,brecha,breve,brillo,brinco,brisa,broca,broma,bronce,brote,bruja,brusco,bruto,buceo,bucle,bueno,buey,bufanda,bufón,búho,buitre,bulto,burbuja,burla,burro,buscar,butaca,buzón,caballo,cabeza,cabina,cabra,cacao,cadáver,cadena,caer,café,caída,caimán,caja,cajón,cal,calamar,calcio,caldo,calidad,calle,calma,calor,calvo,cama,cambio,camello,camino,campo,cáncer,candil,canela,canguro,canica,canto,caña,cañón,caoba,caos,capaz,capitán,capote,captar,capucha,cara,carbón,cárcel,careta,carga,cariño,carne,carpeta,carro,carta,casa,casco,casero,caspa,castor,catorce,catre,caudal,causa,cazo,cebolla,ceder,cedro,celda,célebre,celoso,célula,cemento,ceniza,centro,cerca,cerdo,cereza,cero,cerrar,certeza,césped,cetro,chacal,chaleco,champú,chancla,chapa,charla,chico,chiste,chivo,choque,choza,chuleta,chupar,ciclón,ciego,cielo,cien,cierto,cifra,cigarro,cima,cinco,cine,cinta,ciprés,circo,ciruela,cisne,cita,ciudad,clamor,clan,claro,clase,clave,cliente,clima,clínica,cobre,cocción,cochino,cocina,coco,código,codo,cofre,coger,cohete,cojín,cojo,cola,colcha,colegio,colgar,colina,collar,colmo,columna,combate,comer,comida,cómodo,compra,conde,conejo,conga,conocer,consejo,contar,copa,copia,corazón,corbata,corcho,cordón,corona,correr,coser,cosmos,costa,cráneo,cráter,crear,crecer,creído,crema,cría,crimen,cripta,crisis,cromo,crónica,croqueta,crudo,cruz,cuadro,cuarto,cuatro,cubo,cubrir,cuchara,cuello,cuento,cuerda,cuesta,cueva,cuidar,culebra,culpa,culto,cumbre,cumplir,cuna,cuneta,cuota,cupón,cúpula,curar,curioso,curso,curva,cutis,dama,danza,dar,dardo,dátil,deber,débil,década,decir,dedo,defensa,definir,dejar,delfín,delgado,delito,demora,denso,dental,deporte,derecho,derrota,desayuno,deseo,desfile,desnudo,destino,desvío,detalle,detener,deuda,día,diablo,diadema,diamante,diana,diario,dibujo,dictar,diente,dieta,diez,difícil,digno,dilema,diluir,dinero,directo,dirigir,disco,diseño,disfraz,diva,divino,doble,doce,dolor,domingo,don,donar,dorado,dormir,dorso,dos,dosis,dragón,droga,ducha,duda,duelo,dueño,dulce,dúo,duque,durar,dureza,duro,ébano,ebrio,echar,eco,ecuador,edad,edición,edificio,editor,educar,efecto,eficaz,eje,ejemplo,elefante,elegir,elemento,elevar,elipse,élite,elixir,elogio,eludir,embudo,emitir,emoción,empate,empeño,empleo,empresa,enano,encargo,enchufe,encía,enemigo,enero,enfado,enfermo,engaño,enigma,enlace,enorme,enredo,ensayo,enseñar,entero,entrar,envase,envío,época,equipo,erizo,escala,escena,escolar,escribir,escudo,esencia,esfera,esfuerzo,espada,espejo,espía,esposa,espuma,esquí,estar,este,estilo,estufa,etapa,eterno,ética,etnia,evadir,evaluar,evento,evitar,exacto,examen,exceso,excusa,exento,exigir,exilio,existir,éxito,experto,explicar,exponer,extremo,fábrica,fábula,fachada,fácil,factor,faena,faja,falda,fallo,falso,faltar,fama,familia,famoso,faraón,farmacia,farol,farsa,fase,fatiga,fauna,favor,fax,febrero,fecha,feliz,feo,feria,feroz,fértil,fervor,festín,fiable,fianza,fiar,fibra,ficción,ficha,fideo,fiebre,fiel,fiera,fiesta,figura,fijar,fijo,fila,filete,filial,filtro,fin,finca,fingir,finito,firma,flaco,flauta,flecha,flor,flota,fluir,flujo,flúor,fobia,foca,fogata,fogón,folio,folleto,fondo,forma,forro,fortuna,forzar,fosa,foto,fracaso,frágil,franja,frase,fraude,freír,freno,fresa,frío,frito,fruta,fuego,fuente,fuerza,fuga,fumar,función,funda,furgón,furia,fusil,fútbol,futuro,gacela,gafas,gaita,gajo,gala,galería,gallo,gamba,ganar,gancho,ganga,ganso,garaje,garza,gasolina,gastar,gato,gavilán,gemelo,gemir,gen,género,genio,gente,geranio,gerente,germen,gesto,gigante,gimnasio,girar,giro,glaciar,globo,gloria,gol,golfo,goloso,golpe,goma,gordo,gorila,gorra,gota,goteo,gozar,grada,gráfico,grano,grasa,gratis,grave,grieta,grillo,gripe,gris,grito,grosor,grúa,grueso,grumo,grupo,guante,guapo,guardia,guerra,guía,guiño,guion,guiso,guitarra,gusano,gustar,haber,hábil,hablar,hacer,hacha,hada,hallar,hamaca,harina,haz,hazaña,hebilla,hebra,hecho,helado,helio,hembra,herir,hermano,héroe,hervir,hielo,hierro,hígado,higiene,hijo,himno,historia,hocico,hogar,hoguera,hoja,hombre,hongo,honor,honra,hora,hormiga,horno,hostil,hoyo,hueco,huelga,huerta,hueso,huevo,huida,huir,humano,húmedo,humilde,humo,hundir,huracán,hurto,icono,ideal,idioma,ídolo,iglesia,iglú,igual,ilegal,ilusión,imagen,imán,imitar,impar,imperio,imponer,impulso,incapaz,índice,inerte,infiel,informe,ingenio,inicio,inmenso,inmune,innato,insecto,instante,interés,íntimo,intuir,inútil,invierno,ira,iris,ironía,isla,islote,jabalí,jabón,jamón,jarabe,jardín,jarra,jaula,jazmín,jefe,jeringa,jinete,jornada,joroba,joven,joya,juerga,jueves,juez,jugador,jugo,juguete,juicio,junco,jungla,junio,juntar,júpiter,jurar,justo,juvenil,juzgar,kilo,koala,labio,lacio,lacra,lado,ladrón,lagarto,lágrima,laguna,laico,lamer,lámina,lámpara,lana,lancha,langosta,lanza,lápiz,largo,larva,lástima,lata,látex,latir,laurel,lavar,lazo,leal,lección,leche,lector,leer,legión,legumbre,lejano,lengua,lento,leña,león,leopardo,lesión,letal,letra,leve,leyenda,libertad,libro,licor,líder,lidiar,lienzo,liga,ligero,lima,límite,limón,limpio,lince,lindo,línea,lingote,lino,linterna,líquido,liso,lista,litera,litio,litro,llaga,llama,llanto,llave,llegar,llenar,llevar,llorar,llover,lluvia,lobo,loción,loco,locura,lógica,logro,lombriz,lomo,lonja,lote,lucha,lucir,lugar,lujo,luna,lunes,lupa,lustro,luto,luz,maceta,macho,madera,madre,maduro,maestro,mafia,magia,mago,maíz,maldad,maleta,malla,malo,mamá,mambo,mamut,manco,mando,manejar,manga,maniquí,manjar,mano,manso,manta,mañana,mapa,máquina,mar,marco,marea,marfil,margen,marido,mármol,marrón,martes,marzo,masa,máscara,masivo,matar,materia,matiz,matriz,máximo,mayor,mazorca,mecha,medalla,medio,médula,mejilla,mejor,melena,melón,memoria,menor,mensaje,mente,menú,mercado,merengue,mérito,mes,mesón,meta,meter,método,metro,mezcla,miedo,miel,miembro,miga,mil,milagro,militar,millón,mimo,mina,minero,mínimo,minuto,miope,mirar,misa,miseria,misil,mismo,mitad,mito,mochila,moción,moda,modelo,moho,mojar,molde,moler,molino,momento,momia,monarca,moneda,monja,monto,moño,morada,morder,moreno,morir,morro,morsa,mortal,mosca,mostrar,motivo,mover,móvil,mozo,mucho,mudar,mueble,muela,muerte,muestra,mugre,mujer,mula,muleta,multa,mundo,muñeca,mural,muro,músculo,museo,musgo,música,muslo,nácar,nación,nadar,naipe,naranja,nariz,narrar,nasal,natal,nativo,natural,náusea,naval,nave,navidad,necio,néctar,negar,negocio,negro,neón,nervio,neto,neutro,nevar,nevera,nicho,nido,niebla,nieto,niñez,niño,nítido,nivel,nobleza,noche,nómina,noria,norma,norte,nota,noticia,novato,novela,novio,nube,nuca,núcleo,nudillo,nudo,nuera,nueve,nuez,nulo,número,nutria,oasis,obeso,obispo,objeto,obra,obrero,observar,obtener,obvio,oca,ocaso,océano,ochenta,ocho,ocio,ocre,octavo,octubre,oculto,ocupar,ocurrir,odiar,odio,odisea,oeste,ofensa,oferta,oficio,ofrecer,ogro,oído,oír,ojo,ola,oleada,olfato,olivo,olla,olmo,olor,olvido,ombligo,onda,onza,opaco,opción,ópera,opinar,oponer,optar,óptica,opuesto,oración,orador,oral,órbita,orca,orden,oreja,órgano,orgía,orgullo,oriente,origen,orilla,oro,orquesta,oruga,osadía,oscuro,osezno,oso,ostra,otoño,otro,oveja,óvulo,óxido,oxígeno,oyente,ozono,pacto,padre,paella,página,pago,país,pájaro,palabra,palco,paleta,pálido,palma,paloma,palpar,pan,panal,pánico,pantera,pañuelo,papá,papel,papilla,paquete,parar,parcela,pared,parir,paro,párpado,parque,párrafo,parte,pasar,paseo,pasión,paso,pasta,pata,patio,patria,pausa,pauta,pavo,payaso,peatón,pecado,pecera,pecho,pedal,pedir,pegar,peine,pelar,peldaño,pelea,peligro,pellejo,pelo,peluca,pena,pensar,peñón,peón,peor,pepino,pequeño,pera,percha,perder,pereza,perfil,perico,perla,permiso,perro,persona,pesa,pesca,pésimo,pestaña,pétalo,petróleo,pez,pezuña,picar,pichón,pie,piedra,pierna,pieza,pijama,pilar,piloto,pimienta,pino,pintor,pinza,piña,piojo,pipa,pirata,pisar,piscina,piso,pista,pitón,pizca,placa,plan,plata,playa,plaza,pleito,pleno,plomo,pluma,plural,pobre,poco,poder,podio,poema,poesía,poeta,polen,policía,pollo,polvo,pomada,pomelo,pomo,pompa,poner,porción,portal,posada,poseer,posible,poste,potencia,potro,pozo,prado,precoz,pregunta,premio,prensa,preso,previo,primo,príncipe,prisión,privar,proa,probar,proceso,producto,proeza,profesor,programa,prole,promesa,pronto,propio,próximo,prueba,público,puchero,pudor,pueblo,puerta,puesto,pulga,pulir,pulmón,pulpo,pulso,puma,punto,puñal,puño,pupa,pupila,puré,quedar,queja,quemar,querer,queso,quieto,química,quince,quitar,rábano,rabia,rabo,ración,radical,raíz,rama,rampa,rancho,rango,rapaz,rápido,rapto,rasgo,raspa,rato,rayo,raza,razón,reacción,realidad,rebaño,rebote,recaer,receta,rechazo,recoger,recreo,recto,recurso,red,redondo,reducir,reflejo,reforma,refrán,refugio,regalo,regir,regla,regreso,rehén,reino,reír,reja,relato,relevo,relieve,relleno,reloj,remar,remedio,remo,rencor,rendir,renta,reparto,repetir,reposo,reptil,res,rescate,resina,respeto,resto,resumen,retiro,retorno,retrato,reunir,revés,revista,rey,rezar,rico,riego,rienda,riesgo,rifa,rígido,rigor,rincón,riñón,río,riqueza,risa,ritmo,rito,rizo,roble,roce,rociar,rodar,rodeo,rodilla,roer,rojizo,rojo,romero,romper,ron,ronco,ronda,ropa,ropero,rosa,rosca,rostro,rotar,rubí,rubor,rudo,rueda,rugir,ruido,ruina,ruleta,rulo,rumbo,rumor,ruptura,ruta,rutina,sábado,saber,sabio,sable,sacar,sagaz,sagrado,sala,saldo,salero,salir,salmón,salón,salsa,salto,salud,salvar,samba,sanción,sandía,sanear,sangre,sanidad,sano,santo,sapo,saque,sardina,sartén,sastre,satán,sauna,saxofón,sección,seco,secreto,secta,sed,seguir,seis,sello,selva,semana,semilla,senda,sensor,señal,señor,separar,sepia,sequía,ser,serie,sermón,servir,sesenta,sesión,seta,setenta,severo,sexo,sexto,sidra,siesta,siete,siglo,signo,sílaba,silbar,silencio,silla,símbolo,simio,sirena,sistema,sitio,situar,sobre,socio,sodio,sol,solapa,soldado,soledad,sólido,soltar,solución,sombra,sondeo,sonido,sonoro,sonrisa,sopa,soplar,soporte,sordo,sorpresa,sorteo,sostén,sótano,suave,subir,suceso,sudor,suegra,suelo,sueño,suerte,sufrir,sujeto,sultán,sumar,superar,suplir,suponer,supremo,sur,surco,sureño,surgir,susto,sutil,tabaco,tabique,tabla,tabú,taco,tacto,tajo,talar,talco,talento,talla,talón,tamaño,tambor,tango,tanque,tapa,tapete,tapia,tapón,taquilla,tarde,tarea,tarifa,tarjeta,tarot,tarro,tarta,tatuaje,tauro,taza,tazón,teatro,techo,tecla,técnica,tejado,tejer,tejido,tela,teléfono,tema,temor,templo,tenaz,tender,tener,tenis,tenso,teoría,terapia,terco,término,ternura,terror,tesis,tesoro,testigo,tetera,texto,tez,tibio,tiburón,tiempo,tienda,tierra,tieso,tigre,tijera,tilde,timbre,tímido,timo,tinta,tío,típico,tipo,tira,tirón,titán,títere,título,tiza,toalla,tobillo,tocar,tocino,todo,toga,toldo,tomar,tono,tonto,topar,tope,toque,tórax,torero,tormenta,torneo,toro,torpedo,torre,torso,tortuga,tos,tosco,toser,tóxico,trabajo,tractor,traer,tráfico,trago,traje,tramo,trance,trato,trauma,trazar,trébol,tregua,treinta,tren,trepar,tres,tribu,trigo,tripa,triste,triunfo,trofeo,trompa,tronco,tropa,trote,trozo,truco,trueno,trufa,tubería,tubo,tuerto,tumba,tumor,túnel,túnica,turbina,turismo,turno,tutor,ubicar,úlcera,umbral,unidad,unir,universo,uno,untar,uña,urbano,urbe,urgente,urna,usar,usuario,útil,utopía,uva,vaca,vacío,vacuna,vagar,vago,vaina,vajilla,vale,válido,valle,valor,válvula,vampiro,vara,variar,varón,vaso,vecino,vector,vehículo,veinte,vejez,vela,velero,veloz,vena,vencer,venda,veneno,vengar,venir,venta,venus,ver,verano,verbo,verde,vereda,verja,verso,verter,vía,viaje,vibrar,vicio,víctima,vida,vídeo,vidrio,viejo,viernes,vigor,vil,villa,vinagre,vino,viñedo,violín,viral,virgo,virtud,visor,víspera,vista,vitamina,viudo,vivaz,vivero,vivir,vivo,volcán,volumen,volver,voraz,votar,voto,voz,vuelo,vulgar,yacer,yate,yegua,yema,yerno,yeso,yodo,yoga,yogur,zafiro,zanja,zapato,zarza,zona,zorro,zumo,zurdo" + .split(',') + } + // As per BIP39: + // > Special Spanish characters like 'ñ', 'ü', 'á', etc... + // > are considered equal to 'n', 'u', 'a', etc... + // > in terms of identifying a word. + // + // Dev notes: + // - The 'ü' character doesn't actually appear in the spanish wordlist + override fun matchMap(): Map { + return mapOf("á" to "a", "é" to "e", "í" to "i", "ó" to "o", "ú" to "u", "ñ" to "n") + } + }, + French { + override val code: String get() = "fr" + override fun wordlist(): List { + return "abaisser,abandon,abdiquer,abeille,abolir,aborder,aboutir,aboyer,abrasif,abreuver,abriter,abroger,abrupt,absence,absolu,absurde,abusif,abyssal,académie,acajou,acarien,accabler,accepter,acclamer,accolade,accroche,accuser,acerbe,achat,acheter,aciduler,acier,acompte,acquérir,acronyme,acteur,actif,actuel,adepte,adéquat,adhésif,adjectif,adjuger,admettre,admirer,adopter,adorer,adoucir,adresse,adroit,adulte,adverbe,aérer,aéronef,affaire,affecter,affiche,affreux,affubler,agacer,agencer,agile,agiter,agrafer,agréable,agrume,aider,aiguille,ailier,aimable,aisance,ajouter,ajuster,alarmer,alchimie,alerte,algèbre,algue,aliéner,aliment,alléger,alliage,allouer,allumer,alourdir,alpaga,altesse,alvéole,amateur,ambigu,ambre,aménager,amertume,amidon,amiral,amorcer,amour,amovible,amphibie,ampleur,amusant,analyse,anaphore,anarchie,anatomie,ancien,anéantir,angle,angoisse,anguleux,animal,annexer,annonce,annuel,anodin,anomalie,anonyme,anormal,antenne,antidote,anxieux,apaiser,apéritif,aplanir,apologie,appareil,appeler,apporter,appuyer,aquarium,aqueduc,arbitre,arbuste,ardeur,ardoise,argent,arlequin,armature,armement,armoire,armure,arpenter,arracher,arriver,arroser,arsenic,artériel,article,aspect,asphalte,aspirer,assaut,asservir,assiette,associer,assurer,asticot,astre,astuce,atelier,atome,atrium,atroce,attaque,attentif,attirer,attraper,aubaine,auberge,audace,audible,augurer,aurore,automne,autruche,avaler,avancer,avarice,avenir,averse,aveugle,aviateur,avide,avion,aviser,avoine,avouer,avril,axial,axiome,badge,bafouer,bagage,baguette,baignade,balancer,balcon,baleine,balisage,bambin,bancaire,bandage,banlieue,bannière,banquier,barbier,baril,baron,barque,barrage,bassin,bastion,bataille,bateau,batterie,baudrier,bavarder,belette,bélier,belote,bénéfice,berceau,berger,berline,bermuda,besace,besogne,bétail,beurre,biberon,bicycle,bidule,bijou,bilan,bilingue,billard,binaire,biologie,biopsie,biotype,biscuit,bison,bistouri,bitume,bizarre,blafard,blague,blanchir,blessant,blinder,blond,bloquer,blouson,bobard,bobine,boire,boiser,bolide,bonbon,bondir,bonheur,bonifier,bonus,bordure,borne,botte,boucle,boueux,bougie,boulon,bouquin,bourse,boussole,boutique,boxeur,branche,brasier,brave,brebis,brèche,breuvage,bricoler,brigade,brillant,brioche,brique,brochure,broder,bronzer,brousse,broyeur,brume,brusque,brutal,bruyant,buffle,buisson,bulletin,bureau,burin,bustier,butiner,butoir,buvable,buvette,cabanon,cabine,cachette,cadeau,cadre,caféine,caillou,caisson,calculer,calepin,calibre,calmer,calomnie,calvaire,camarade,caméra,camion,campagne,canal,caneton,canon,cantine,canular,capable,caporal,caprice,capsule,capter,capuche,carabine,carbone,caresser,caribou,carnage,carotte,carreau,carton,cascade,casier,casque,cassure,causer,caution,cavalier,caverne,caviar,cédille,ceinture,céleste,cellule,cendrier,censurer,central,cercle,cérébral,cerise,cerner,cerveau,cesser,chagrin,chaise,chaleur,chambre,chance,chapitre,charbon,chasseur,chaton,chausson,chavirer,chemise,chenille,chéquier,chercher,cheval,chien,chiffre,chignon,chimère,chiot,chlorure,chocolat,choisir,chose,chouette,chrome,chute,cigare,cigogne,cimenter,cinéma,cintrer,circuler,cirer,cirque,citerne,citoyen,citron,civil,clairon,clameur,claquer,classe,clavier,client,cligner,climat,clivage,cloche,clonage,cloporte,cobalt,cobra,cocasse,cocotier,coder,codifier,coffre,cogner,cohésion,coiffer,coincer,colère,colibri,colline,colmater,colonel,combat,comédie,commande,compact,concert,conduire,confier,congeler,connoter,consonne,contact,convexe,copain,copie,corail,corbeau,cordage,corniche,corpus,correct,cortège,cosmique,costume,coton,coude,coupure,courage,couteau,couvrir,coyote,crabe,crainte,cravate,crayon,créature,créditer,crémeux,creuser,crevette,cribler,crier,cristal,critère,croire,croquer,crotale,crucial,cruel,crypter,cubique,cueillir,cuillère,cuisine,cuivre,culminer,cultiver,cumuler,cupide,curatif,curseur,cyanure,cycle,cylindre,cynique,daigner,damier,danger,danseur,dauphin,débattre,débiter,déborder,débrider,débutant,décaler,décembre,déchirer,décider,déclarer,décorer,décrire,décupler,dédale,déductif,déesse,défensif,défiler,défrayer,dégager,dégivrer,déglutir,dégrafer,déjeuner,délice,déloger,demander,demeurer,démolir,dénicher,dénouer,dentelle,dénuder,départ,dépenser,déphaser,déplacer,déposer,déranger,dérober,désastre,descente,désert,désigner,désobéir,dessiner,destrier,détacher,détester,détourer,détresse,devancer,devenir,deviner,devoir,diable,dialogue,diamant,dicter,différer,digérer,digital,digne,diluer,dimanche,diminuer,dioxyde,directif,diriger,discuter,disposer,dissiper,distance,divertir,diviser,docile,docteur,dogme,doigt,domaine,domicile,dompter,donateur,donjon,donner,dopamine,dortoir,dorure,dosage,doseur,dossier,dotation,douanier,double,douceur,douter,doyen,dragon,draper,dresser,dribbler,droiture,duperie,duplexe,durable,durcir,dynastie,éblouir,écarter,écharpe,échelle,éclairer,éclipse,éclore,écluse,école,économie,écorce,écouter,écraser,écrémer,écrivain,écrou,écume,écureuil,édifier,éduquer,effacer,effectif,effigie,effort,effrayer,effusion,égaliser,égarer,éjecter,élaborer,élargir,électron,élégant,éléphant,élève,éligible,élitisme,éloge,élucider,éluder,emballer,embellir,embryon,émeraude,émission,emmener,émotion,émouvoir,empereur,employer,emporter,emprise,émulsion,encadrer,enchère,enclave,encoche,endiguer,endosser,endroit,enduire,énergie,enfance,enfermer,enfouir,engager,engin,englober,énigme,enjamber,enjeu,enlever,ennemi,ennuyeux,enrichir,enrobage,enseigne,entasser,entendre,entier,entourer,entraver,énumérer,envahir,enviable,envoyer,enzyme,éolien,épaissir,épargne,épatant,épaule,épicerie,épidémie,épier,épilogue,épine,épisode,épitaphe,époque,épreuve,éprouver,épuisant,équerre,équipe,ériger,érosion,erreur,éruption,escalier,espadon,espèce,espiègle,espoir,esprit,esquiver,essayer,essence,essieu,essorer,estime,estomac,estrade,étagère,étaler,étanche,étatique,éteindre,étendoir,éternel,éthanol,éthique,ethnie,étirer,étoffer,étoile,étonnant,étourdir,étrange,étroit,étude,euphorie,évaluer,évasion,éventail,évidence,éviter,évolutif,évoquer,exact,exagérer,exaucer,exceller,excitant,exclusif,excuse,exécuter,exemple,exercer,exhaler,exhorter,exigence,exiler,exister,exotique,expédier,explorer,exposer,exprimer,exquis,extensif,extraire,exulter,fable,fabuleux,facette,facile,facture,faiblir,falaise,fameux,famille,farceur,farfelu,farine,farouche,fasciner,fatal,fatigue,faucon,fautif,faveur,favori,fébrile,féconder,fédérer,félin,femme,fémur,fendoir,féodal,fermer,féroce,ferveur,festival,feuille,feutre,février,fiasco,ficeler,fictif,fidèle,figure,filature,filetage,filière,filleul,filmer,filou,filtrer,financer,finir,fiole,firme,fissure,fixer,flairer,flamme,flasque,flatteur,fléau,flèche,fleur,flexion,flocon,flore,fluctuer,fluide,fluvial,folie,fonderie,fongible,fontaine,forcer,forgeron,formuler,fortune,fossile,foudre,fougère,fouiller,foulure,fourmi,fragile,fraise,franchir,frapper,frayeur,frégate,freiner,frelon,frémir,frénésie,frère,friable,friction,frisson,frivole,froid,fromage,frontal,frotter,fruit,fugitif,fuite,fureur,furieux,furtif,fusion,futur,gagner,galaxie,galerie,gambader,garantir,gardien,garnir,garrigue,gazelle,gazon,géant,gélatine,gélule,gendarme,général,génie,genou,gentil,géologie,géomètre,géranium,germe,gestuel,geyser,gibier,gicler,girafe,givre,glace,glaive,glisser,globe,gloire,glorieux,golfeur,gomme,gonfler,gorge,gorille,goudron,gouffre,goulot,goupille,gourmand,goutte,graduel,graffiti,graine,grand,grappin,gratuit,gravir,grenat,griffure,griller,grimper,grogner,gronder,grotte,groupe,gruger,grutier,gruyère,guépard,guerrier,guide,guimauve,guitare,gustatif,gymnaste,gyrostat,habitude,hachoir,halte,hameau,hangar,hanneton,haricot,harmonie,harpon,hasard,hélium,hématome,herbe,hérisson,hermine,héron,hésiter,heureux,hiberner,hibou,hilarant,histoire,hiver,homard,hommage,homogène,honneur,honorer,honteux,horde,horizon,horloge,hormone,horrible,houleux,housse,hublot,huileux,humain,humble,humide,humour,hurler,hydromel,hygiène,hymne,hypnose,idylle,ignorer,iguane,illicite,illusion,image,imbiber,imiter,immense,immobile,immuable,impact,impérial,implorer,imposer,imprimer,imputer,incarner,incendie,incident,incliner,incolore,indexer,indice,inductif,inédit,ineptie,inexact,infini,infliger,informer,infusion,ingérer,inhaler,inhiber,injecter,injure,innocent,inoculer,inonder,inscrire,insecte,insigne,insolite,inspirer,instinct,insulter,intact,intense,intime,intrigue,intuitif,inutile,invasion,inventer,inviter,invoquer,ironique,irradier,irréel,irriter,isoler,ivoire,ivresse,jaguar,jaillir,jambe,janvier,jardin,jauger,jaune,javelot,jetable,jeton,jeudi,jeunesse,joindre,joncher,jongler,joueur,jouissif,journal,jovial,joyau,joyeux,jubiler,jugement,junior,jupon,juriste,justice,juteux,juvénile,kayak,kimono,kiosque,label,labial,labourer,lacérer,lactose,lagune,laine,laisser,laitier,lambeau,lamelle,lampe,lanceur,langage,lanterne,lapin,largeur,larme,laurier,lavabo,lavoir,lecture,légal,léger,légume,lessive,lettre,levier,lexique,lézard,liasse,libérer,libre,licence,licorne,liège,lièvre,ligature,ligoter,ligue,limer,limite,limonade,limpide,linéaire,lingot,lionceau,liquide,lisière,lister,lithium,litige,littoral,livreur,logique,lointain,loisir,lombric,loterie,louer,lourd,loutre,louve,loyal,lubie,lucide,lucratif,lueur,lugubre,luisant,lumière,lunaire,lundi,luron,lutter,luxueux,machine,magasin,magenta,magique,maigre,maillon,maintien,mairie,maison,majorer,malaxer,maléfice,malheur,malice,mallette,mammouth,mandater,maniable,manquant,manteau,manuel,marathon,marbre,marchand,mardi,maritime,marqueur,marron,marteler,mascotte,massif,matériel,matière,matraque,maudire,maussade,mauve,maximal,méchant,méconnu,médaille,médecin,méditer,méduse,meilleur,mélange,mélodie,membre,mémoire,menacer,mener,menhir,mensonge,mentor,mercredi,mérite,merle,messager,mesure,métal,météore,méthode,métier,meuble,miauler,microbe,miette,mignon,migrer,milieu,million,mimique,mince,minéral,minimal,minorer,minute,miracle,miroiter,missile,mixte,mobile,moderne,moelleux,mondial,moniteur,monnaie,monotone,monstre,montagne,monument,moqueur,morceau,morsure,mortier,moteur,motif,mouche,moufle,moulin,mousson,mouton,mouvant,multiple,munition,muraille,murène,murmure,muscle,muséum,musicien,mutation,muter,mutuel,myriade,myrtille,mystère,mythique,nageur,nappe,narquois,narrer,natation,nation,nature,naufrage,nautique,navire,nébuleux,nectar,néfaste,négation,négliger,négocier,neige,nerveux,nettoyer,neurone,neutron,neveu,niche,nickel,nitrate,niveau,noble,nocif,nocturne,noirceur,noisette,nomade,nombreux,nommer,normatif,notable,notifier,notoire,nourrir,nouveau,novateur,novembre,novice,nuage,nuancer,nuire,nuisible,numéro,nuptial,nuque,nutritif,obéir,objectif,obliger,obscur,observer,obstacle,obtenir,obturer,occasion,occuper,océan,octobre,octroyer,octupler,oculaire,odeur,odorant,offenser,officier,offrir,ogive,oiseau,oisillon,olfactif,olivier,ombrage,omettre,onctueux,onduler,onéreux,onirique,opale,opaque,opérer,opinion,opportun,opprimer,opter,optique,orageux,orange,orbite,ordonner,oreille,organe,orgueil,orifice,ornement,orque,ortie,osciller,osmose,ossature,otarie,ouragan,ourson,outil,outrager,ouvrage,ovation,oxyde,oxygène,ozone,paisible,palace,palmarès,palourde,palper,panache,panda,pangolin,paniquer,panneau,panorama,pantalon,papaye,papier,papoter,papyrus,paradoxe,parcelle,paresse,parfumer,parler,parole,parrain,parsemer,partager,parure,parvenir,passion,pastèque,paternel,patience,patron,pavillon,pavoiser,payer,paysage,peigne,peintre,pelage,pélican,pelle,pelouse,peluche,pendule,pénétrer,pénible,pensif,pénurie,pépite,péplum,perdrix,perforer,période,permuter,perplexe,persil,perte,peser,pétale,petit,pétrir,peuple,pharaon,phobie,phoque,photon,phrase,physique,piano,pictural,pièce,pierre,pieuvre,pilote,pinceau,pipette,piquer,pirogue,piscine,piston,pivoter,pixel,pizza,placard,plafond,plaisir,planer,plaque,plastron,plateau,pleurer,plexus,pliage,plomb,plonger,pluie,plumage,pochette,poésie,poète,pointe,poirier,poisson,poivre,polaire,policier,pollen,polygone,pommade,pompier,ponctuel,pondérer,poney,portique,position,posséder,posture,potager,poteau,potion,pouce,poulain,poumon,pourpre,poussin,pouvoir,prairie,pratique,précieux,prédire,préfixe,prélude,prénom,présence,prétexte,prévoir,primitif,prince,prison,priver,problème,procéder,prodige,profond,progrès,proie,projeter,prologue,promener,propre,prospère,protéger,prouesse,proverbe,prudence,pruneau,psychose,public,puceron,puiser,pulpe,pulsar,punaise,punitif,pupitre,purifier,puzzle,pyramide,quasar,querelle,question,quiétude,quitter,quotient,racine,raconter,radieux,ragondin,raideur,raisin,ralentir,rallonge,ramasser,rapide,rasage,ratisser,ravager,ravin,rayonner,réactif,réagir,réaliser,réanimer,recevoir,réciter,réclamer,récolter,recruter,reculer,recycler,rédiger,redouter,refaire,réflexe,réformer,refrain,refuge,régalien,région,réglage,régulier,réitérer,rejeter,rejouer,relatif,relever,relief,remarque,remède,remise,remonter,remplir,remuer,renard,renfort,renifler,renoncer,rentrer,renvoi,replier,reporter,reprise,reptile,requin,réserve,résineux,résoudre,respect,rester,résultat,rétablir,retenir,réticule,retomber,retracer,réunion,réussir,revanche,revivre,révolte,révulsif,richesse,rideau,rieur,rigide,rigoler,rincer,riposter,risible,risque,rituel,rival,rivière,rocheux,romance,rompre,ronce,rondin,roseau,rosier,rotatif,rotor,rotule,rouge,rouille,rouleau,routine,royaume,ruban,rubis,ruche,ruelle,rugueux,ruiner,ruisseau,ruser,rustique,rythme,sabler,saboter,sabre,sacoche,safari,sagesse,saisir,salade,salive,salon,saluer,samedi,sanction,sanglier,sarcasme,sardine,saturer,saugrenu,saumon,sauter,sauvage,savant,savonner,scalpel,scandale,scélérat,scénario,sceptre,schéma,science,scinder,score,scrutin,sculpter,séance,sécable,sécher,secouer,sécréter,sédatif,séduire,seigneur,séjour,sélectif,semaine,sembler,semence,séminal,sénateur,sensible,sentence,séparer,séquence,serein,sergent,sérieux,serrure,sérum,service,sésame,sévir,sevrage,sextuple,sidéral,siècle,siéger,siffler,sigle,signal,silence,silicium,simple,sincère,sinistre,siphon,sirop,sismique,situer,skier,social,socle,sodium,soigneux,soldat,soleil,solitude,soluble,sombre,sommeil,somnoler,sonde,songeur,sonnette,sonore,sorcier,sortir,sosie,sottise,soucieux,soudure,souffle,soulever,soupape,source,soutirer,souvenir,spacieux,spatial,spécial,sphère,spiral,stable,station,sternum,stimulus,stipuler,strict,studieux,stupeur,styliste,sublime,substrat,subtil,subvenir,succès,sucre,suffixe,suggérer,suiveur,sulfate,superbe,supplier,surface,suricate,surmener,surprise,sursaut,survie,suspect,syllabe,symbole,symétrie,synapse,syntaxe,système,tabac,tablier,tactile,tailler,talent,talisman,talonner,tambour,tamiser,tangible,tapis,taquiner,tarder,tarif,tartine,tasse,tatami,tatouage,taupe,taureau,taxer,témoin,temporel,tenaille,tendre,teneur,tenir,tension,terminer,terne,terrible,tétine,texte,thème,théorie,thérapie,thorax,tibia,tiède,timide,tirelire,tiroir,tissu,titane,titre,tituber,toboggan,tolérant,tomate,tonique,tonneau,toponyme,torche,tordre,tornade,torpille,torrent,torse,tortue,totem,toucher,tournage,tousser,toxine,traction,trafic,tragique,trahir,train,trancher,travail,trèfle,tremper,trésor,treuil,triage,tribunal,tricoter,trilogie,triomphe,tripler,triturer,trivial,trombone,tronc,tropical,troupeau,tuile,tulipe,tumulte,tunnel,turbine,tuteur,tutoyer,tuyau,tympan,typhon,typique,tyran,ubuesque,ultime,ultrason,unanime,unifier,union,unique,unitaire,univers,uranium,urbain,urticant,usage,usine,usuel,usure,utile,utopie,vacarme,vaccin,vagabond,vague,vaillant,vaincre,vaisseau,valable,valise,vallon,valve,vampire,vanille,vapeur,varier,vaseux,vassal,vaste,vecteur,vedette,végétal,véhicule,veinard,véloce,vendredi,vénérer,venger,venimeux,ventouse,verdure,vérin,vernir,verrou,verser,vertu,veston,vétéran,vétuste,vexant,vexer,viaduc,viande,victoire,vidange,vidéo,vignette,vigueur,vilain,village,vinaigre,violon,vipère,virement,virtuose,virus,visage,viseur,vision,visqueux,visuel,vital,vitesse,viticole,vitrine,vivace,vivipare,vocation,voguer,voile,voisin,voiture,volaille,volcan,voltiger,volume,vorace,vortex,voter,vouloir,voyage,voyelle,wagon,xénon,yacht,zèbre,zénith,zeste,zoologie" + .split(',') + } + // As per BIP39: + // > Special French characters "é-è" are considered equal to "e". + // > No words with "ô;â;ç;ê;œ;æ;î;ï;û;ù;à;ë;ÿ". + override fun matchMap(): Map { + return mapOf("é" to "e", "è" to "e") + } + }, + Czech { + override val code: String get() = "cs" + override fun wordlist(): List { + return "abdikace,abeceda,adresa,agrese,akce,aktovka,alej,alkohol,amputace,ananas,andulka,anekdota,anketa,antika,anulovat,archa,arogance,asfalt,asistent,aspirace,astma,astronom,atlas,atletika,atol,autobus,azyl,babka,bachor,bacil,baculka,badatel,bageta,bagr,bahno,bakterie,balada,baletka,balkon,balonek,balvan,balza,bambus,bankomat,barbar,baret,barman,baroko,barva,baterka,batoh,bavlna,bazalka,bazilika,bazuka,bedna,beran,beseda,bestie,beton,bezinka,bezmoc,beztak,bicykl,bidlo,biftek,bikiny,bilance,biograf,biolog,bitva,bizon,blahobyt,blatouch,blecha,bledule,blesk,blikat,blizna,blokovat,bloudit,blud,bobek,bobr,bodlina,bodnout,bohatost,bojkot,bojovat,bokorys,bolest,borec,borovice,bota,boubel,bouchat,bouda,boule,bourat,boxer,bradavka,brambora,branka,bratr,brepta,briketa,brko,brloh,bronz,broskev,brunetka,brusinka,brzda,brzy,bublina,bubnovat,buchta,buditel,budka,budova,bufet,bujarost,bukvice,buldok,bulva,bunda,bunkr,burza,butik,buvol,buzola,bydlet,bylina,bytovka,bzukot,capart,carevna,cedr,cedule,cejch,cejn,cela,celer,celkem,celnice,cenina,cennost,cenovka,centrum,cenzor,cestopis,cetka,chalupa,chapadlo,charita,chata,chechtat,chemie,chichot,chirurg,chlad,chleba,chlubit,chmel,chmura,chobot,chochol,chodba,cholera,chomout,chopit,choroba,chov,chrapot,chrlit,chrt,chrup,chtivost,chudina,chutnat,chvat,chvilka,chvost,chyba,chystat,chytit,cibule,cigareta,cihelna,cihla,cinkot,cirkus,cisterna,citace,citrus,cizinec,cizost,clona,cokoliv,couvat,ctitel,ctnost,cudnost,cuketa,cukr,cupot,cvaknout,cval,cvik,cvrkot,cyklista,daleko,dareba,datel,datum,dcera,debata,dechovka,decibel,deficit,deflace,dekl,dekret,demokrat,deprese,derby,deska,detektiv,dikobraz,diktovat,dioda,diplom,disk,displej,divadlo,divoch,dlaha,dlouho,dluhopis,dnes,dobro,dobytek,docent,dochutit,dodnes,dohled,dohoda,dohra,dojem,dojnice,doklad,dokola,doktor,dokument,dolar,doleva,dolina,doma,dominant,domluvit,domov,donutit,dopad,dopis,doplnit,doposud,doprovod,dopustit,dorazit,dorost,dort,dosah,doslov,dostatek,dosud,dosyta,dotaz,dotek,dotknout,doufat,doutnat,dovozce,dozadu,doznat,dozorce,drahota,drak,dramatik,dravec,draze,drdol,drobnost,drogerie,drozd,drsnost,drtit,drzost,duben,duchovno,dudek,duha,duhovka,dusit,dusno,dutost,dvojice,dvorec,dynamit,ekolog,ekonomie,elektron,elipsa,email,emise,emoce,empatie,epizoda,epocha,epopej,epos,esej,esence,eskorta,eskymo,etiketa,euforie,evoluce,exekuce,exkurze,expedice,exploze,export,extrakt,facka,fajfka,fakulta,fanatik,fantazie,farmacie,favorit,fazole,federace,fejeton,fenka,fialka,figurant,filozof,filtr,finance,finta,fixace,fjord,flanel,flirt,flotila,fond,fosfor,fotbal,fotka,foton,frakce,freska,fronta,fukar,funkce,fyzika,galeje,garant,genetika,geolog,gilotina,glazura,glejt,golem,golfista,gotika,graf,gramofon,granule,grep,gril,grog,groteska,guma,hadice,hadr,hala,halenka,hanba,hanopis,harfa,harpuna,havran,hebkost,hejkal,hejno,hejtman,hektar,helma,hematom,herec,herna,heslo,hezky,historik,hladovka,hlasivky,hlava,hledat,hlen,hlodavec,hloh,hloupost,hltat,hlubina,hluchota,hmat,hmota,hmyz,hnis,hnojivo,hnout,hoblina,hoboj,hoch,hodiny,hodlat,hodnota,hodovat,hojnost,hokej,holinka,holka,holub,homole,honitba,honorace,horal,horda,horizont,horko,horlivec,hormon,hornina,horoskop,horstvo,hospoda,hostina,hotovost,houba,houf,houpat,houska,hovor,hradba,hranice,hravost,hrazda,hrbolek,hrdina,hrdlo,hrdost,hrnek,hrobka,hromada,hrot,hrouda,hrozen,hrstka,hrubost,hryzat,hubenost,hubnout,hudba,hukot,humr,husita,hustota,hvozd,hybnost,hydrant,hygiena,hymna,hysterik,idylka,ihned,ikona,iluze,imunita,infekce,inflace,inkaso,inovace,inspekce,internet,invalida,investor,inzerce,ironie,jablko,jachta,jahoda,jakmile,jakost,jalovec,jantar,jarmark,jaro,jasan,jasno,jatka,javor,jazyk,jedinec,jedle,jednatel,jehlan,jekot,jelen,jelito,jemnost,jenom,jepice,jeseter,jevit,jezdec,jezero,jinak,jindy,jinoch,jiskra,jistota,jitrnice,jizva,jmenovat,jogurt,jurta,kabaret,kabel,kabinet,kachna,kadet,kadidlo,kahan,kajak,kajuta,kakao,kaktus,kalamita,kalhoty,kalibr,kalnost,kamera,kamkoliv,kamna,kanibal,kanoe,kantor,kapalina,kapela,kapitola,kapka,kaple,kapota,kapr,kapusta,kapybara,karamel,karotka,karton,kasa,katalog,katedra,kauce,kauza,kavalec,kazajka,kazeta,kazivost,kdekoliv,kdesi,kedluben,kemp,keramika,kino,klacek,kladivo,klam,klapot,klasika,klaun,klec,klenba,klepat,klesnout,klid,klima,klisna,klobouk,klokan,klopa,kloub,klubovna,klusat,kluzkost,kmen,kmitat,kmotr,kniha,knot,koalice,koberec,kobka,kobliha,kobyla,kocour,kohout,kojenec,kokos,koktejl,kolaps,koleda,kolize,kolo,komando,kometa,komik,komnata,komora,kompas,komunita,konat,koncept,kondice,konec,konfese,kongres,konina,konkurs,kontakt,konzerva,kopanec,kopie,kopnout,koprovka,korbel,korektor,kormidlo,koroptev,korpus,koruna,koryto,korzet,kosatec,kostka,kotel,kotleta,kotoul,koukat,koupelna,kousek,kouzlo,kovboj,koza,kozoroh,krabice,krach,krajina,kralovat,krasopis,kravata,kredit,krejcar,kresba,kreveta,kriket,kritik,krize,krkavec,krmelec,krmivo,krocan,krok,kronika,kropit,kroupa,krovka,krtek,kruhadlo,krupice,krutost,krvinka,krychle,krypta,krystal,kryt,kudlanka,kufr,kujnost,kukla,kulajda,kulich,kulka,kulomet,kultura,kuna,kupodivu,kurt,kurzor,kutil,kvalita,kvasinka,kvestor,kynolog,kyselina,kytara,kytice,kytka,kytovec,kyvadlo,labrador,lachtan,ladnost,laik,lakomec,lamela,lampa,lanovka,lasice,laso,lastura,latinka,lavina,lebka,leckdy,leden,lednice,ledovka,ledvina,legenda,legie,legrace,lehce,lehkost,lehnout,lektvar,lenochod,lentilka,lepenka,lepidlo,letadlo,letec,letmo,letokruh,levhart,levitace,levobok,libra,lichotka,lidojed,lidskost,lihovina,lijavec,lilek,limetka,linie,linka,linoleum,listopad,litina,litovat,lobista,lodivod,logika,logoped,lokalita,loket,lomcovat,lopata,lopuch,lord,losos,lotr,loudal,louh,louka,louskat,lovec,lstivost,lucerna,lucifer,lump,lusk,lustrace,lvice,lyra,lyrika,lysina,madam,madlo,magistr,mahagon,majetek,majitel,majorita,makak,makovice,makrela,malba,malina,malovat,malvice,maminka,mandle,manko,marnost,masakr,maskot,masopust,matice,matrika,maturita,mazanec,mazivo,mazlit,mazurka,mdloba,mechanik,meditace,medovina,melasa,meloun,mentolka,metla,metoda,metr,mezera,migrace,mihnout,mihule,mikina,mikrofon,milenec,milimetr,milost,mimika,mincovna,minibar,minomet,minulost,miska,mistr,mixovat,mladost,mlha,mlhovina,mlok,mlsat,mluvit,mnich,mnohem,mobil,mocnost,modelka,modlitba,mohyla,mokro,molekula,momentka,monarcha,monokl,monstrum,montovat,monzun,mosaz,moskyt,most,motivace,motorka,motyka,moucha,moudrost,mozaika,mozek,mozol,mramor,mravenec,mrkev,mrtvola,mrzet,mrzutost,mstitel,mudrc,muflon,mulat,mumie,munice,muset,mutace,muzeum,muzikant,myslivec,mzda,nabourat,nachytat,nadace,nadbytek,nadhoz,nadobro,nadpis,nahlas,nahnat,nahodile,nahradit,naivita,najednou,najisto,najmout,naklonit,nakonec,nakrmit,nalevo,namazat,namluvit,nanometr,naoko,naopak,naostro,napadat,napevno,naplnit,napnout,naposled,naprosto,narodit,naruby,narychlo,nasadit,nasekat,naslepo,nastat,natolik,navenek,navrch,navzdory,nazvat,nebe,nechat,necky,nedaleko,nedbat,neduh,negace,nehet,nehoda,nejen,nejprve,neklid,nelibost,nemilost,nemoc,neochota,neonka,nepokoj,nerost,nerv,nesmysl,nesoulad,netvor,neuron,nevina,nezvykle,nicota,nijak,nikam,nikdy,nikl,nikterak,nitro,nocleh,nohavice,nominace,nora,norek,nositel,nosnost,nouze,noviny,novota,nozdra,nuda,nudle,nuget,nutit,nutnost,nutrie,nymfa,obal,obarvit,obava,obdiv,obec,obehnat,obejmout,obezita,obhajoba,obilnice,objasnit,objekt,obklopit,oblast,oblek,obliba,obloha,obluda,obnos,obohatit,obojek,obout,obrazec,obrna,obruba,obrys,obsah,obsluha,obstarat,obuv,obvaz,obvinit,obvod,obvykle,obyvatel,obzor,ocas,ocel,ocenit,ochladit,ochota,ochrana,ocitnout,odboj,odbyt,odchod,odcizit,odebrat,odeslat,odevzdat,odezva,odhadce,odhodit,odjet,odjinud,odkaz,odkoupit,odliv,odluka,odmlka,odolnost,odpad,odpis,odplout,odpor,odpustit,odpykat,odrazka,odsoudit,odstup,odsun,odtok,odtud,odvaha,odveta,odvolat,odvracet,odznak,ofina,ofsajd,ohlas,ohnisko,ohrada,ohrozit,ohryzek,okap,okenice,oklika,okno,okouzlit,okovy,okrasa,okres,okrsek,okruh,okupant,okurka,okusit,olejnina,olizovat,omak,omeleta,omezit,omladina,omlouvat,omluva,omyl,onehdy,opakovat,opasek,operace,opice,opilost,opisovat,opora,opozice,opravdu,oproti,orbital,orchestr,orgie,orlice,orloj,ortel,osada,oschnout,osika,osivo,oslava,oslepit,oslnit,oslovit,osnova,osoba,osolit,ospalec,osten,ostraha,ostuda,ostych,osvojit,oteplit,otisk,otop,otrhat,otrlost,otrok,otruby,otvor,ovanout,ovar,oves,ovlivnit,ovoce,oxid,ozdoba,pachatel,pacient,padouch,pahorek,pakt,palanda,palec,palivo,paluba,pamflet,pamlsek,panenka,panika,panna,panovat,panstvo,pantofle,paprika,parketa,parodie,parta,paruka,paryba,paseka,pasivita,pastelka,patent,patrona,pavouk,pazneht,pazourek,pecka,pedagog,pejsek,peklo,peloton,penalta,pendrek,penze,periskop,pero,pestrost,petarda,petice,petrolej,pevnina,pexeso,pianista,piha,pijavice,pikle,piknik,pilina,pilnost,pilulka,pinzeta,pipeta,pisatel,pistole,pitevna,pivnice,pivovar,placenta,plakat,plamen,planeta,plastika,platit,plavidlo,plaz,plech,plemeno,plenta,ples,pletivo,plevel,plivat,plnit,plno,plocha,plodina,plomba,plout,pluk,plyn,pobavit,pobyt,pochod,pocit,poctivec,podat,podcenit,podepsat,podhled,podivit,podklad,podmanit,podnik,podoba,podpora,podraz,podstata,podvod,podzim,poezie,pohanka,pohnutka,pohovor,pohroma,pohyb,pointa,pojistka,pojmout,pokazit,pokles,pokoj,pokrok,pokuta,pokyn,poledne,polibek,polknout,poloha,polynom,pomalu,pominout,pomlka,pomoc,pomsta,pomyslet,ponechat,ponorka,ponurost,popadat,popel,popisek,poplach,poprosit,popsat,popud,poradce,porce,porod,porucha,poryv,posadit,posed,posila,poskok,poslanec,posoudit,pospolu,postava,posudek,posyp,potah,potkan,potlesk,potomek,potrava,potupa,potvora,poukaz,pouto,pouzdro,povaha,povidla,povlak,povoz,povrch,povstat,povyk,povzdech,pozdrav,pozemek,poznatek,pozor,pozvat,pracovat,prahory,praktika,prales,praotec,praporek,prase,pravda,princip,prkno,probudit,procento,prodej,profese,prohra,projekt,prolomit,promile,pronikat,propad,prorok,prosba,proton,proutek,provaz,prskavka,prsten,prudkost,prut,prvek,prvohory,psanec,psovod,pstruh,ptactvo,puberta,puch,pudl,pukavec,puklina,pukrle,pult,pumpa,punc,pupen,pusa,pusinka,pustina,putovat,putyka,pyramida,pysk,pytel,racek,rachot,radiace,radnice,radon,raft,ragby,raketa,rakovina,rameno,rampouch,rande,rarach,rarita,rasovna,rastr,ratolest,razance,razidlo,reagovat,reakce,recept,redaktor,referent,reflex,rejnok,reklama,rekord,rekrut,rektor,reputace,revize,revma,revolver,rezerva,riskovat,riziko,robotika,rodokmen,rohovka,rokle,rokoko,romaneto,ropovod,ropucha,rorejs,rosol,rostlina,rotmistr,rotoped,rotunda,roubenka,roucho,roup,roura,rovina,rovnice,rozbor,rozchod,rozdat,rozeznat,rozhodce,rozinka,rozjezd,rozkaz,rozloha,rozmar,rozpad,rozruch,rozsah,roztok,rozum,rozvod,rubrika,ruchadlo,rukavice,rukopis,ryba,rybolov,rychlost,rydlo,rypadlo,rytina,ryzost,sadista,sahat,sako,samec,samizdat,samota,sanitka,sardinka,sasanka,satelit,sazba,sazenice,sbor,schovat,sebranka,secese,sedadlo,sediment,sedlo,sehnat,sejmout,sekera,sekta,sekunda,sekvoje,semeno,seno,servis,sesadit,seshora,seskok,seslat,sestra,sesuv,sesypat,setba,setina,setkat,setnout,setrvat,sever,seznam,shoda,shrnout,sifon,silnice,sirka,sirotek,sirup,situace,skafandr,skalisko,skanzen,skaut,skeptik,skica,skladba,sklenice,sklo,skluz,skoba,skokan,skoro,skripta,skrz,skupina,skvost,skvrna,slabika,sladidlo,slanina,slast,slavnost,sledovat,slepec,sleva,slezina,slib,slina,sliznice,slon,sloupek,slovo,sluch,sluha,slunce,slupka,slza,smaragd,smetana,smilstvo,smlouva,smog,smrad,smrk,smrtka,smutek,smysl,snad,snaha,snob,sobota,socha,sodovka,sokol,sopka,sotva,souboj,soucit,soudce,souhlas,soulad,soumrak,souprava,soused,soutok,souviset,spalovna,spasitel,spis,splav,spodek,spojenec,spolu,sponzor,spornost,spousta,sprcha,spustit,sranda,sraz,srdce,srna,srnec,srovnat,srpen,srst,srub,stanice,starosta,statika,stavba,stehno,stezka,stodola,stolek,stopa,storno,stoupat,strach,stres,strhnout,strom,struna,studna,stupnice,stvol,styk,subjekt,subtropy,suchar,sudost,sukno,sundat,sunout,surikata,surovina,svah,svalstvo,svetr,svatba,svazek,svisle,svitek,svoboda,svodidlo,svorka,svrab,sykavka,sykot,synek,synovec,sypat,sypkost,syrovost,sysel,sytost,tabletka,tabule,tahoun,tajemno,tajfun,tajga,tajit,tajnost,taktika,tamhle,tampon,tancovat,tanec,tanker,tapeta,tavenina,tazatel,technika,tehdy,tekutina,telefon,temnota,tendence,tenista,tenor,teplota,tepna,teprve,terapie,termoska,textil,ticho,tiskopis,titulek,tkadlec,tkanina,tlapka,tleskat,tlukot,tlupa,tmel,toaleta,topinka,topol,torzo,touha,toulec,tradice,traktor,tramp,trasa,traverza,trefit,trest,trezor,trhavina,trhlina,trochu,trojice,troska,trouba,trpce,trpitel,trpkost,trubec,truchlit,truhlice,trus,trvat,tudy,tuhnout,tuhost,tundra,turista,turnaj,tuzemsko,tvaroh,tvorba,tvrdost,tvrz,tygr,tykev,ubohost,uboze,ubrat,ubrousek,ubrus,ubytovna,ucho,uctivost,udivit,uhradit,ujednat,ujistit,ujmout,ukazatel,uklidnit,uklonit,ukotvit,ukrojit,ulice,ulita,ulovit,umyvadlo,unavit,uniforma,uniknout,upadnout,uplatnit,uplynout,upoutat,upravit,uran,urazit,usednout,usilovat,usmrtit,usnadnit,usnout,usoudit,ustlat,ustrnout,utahovat,utkat,utlumit,utonout,utopenec,utrousit,uvalit,uvolnit,uvozovka,uzdravit,uzel,uzenina,uzlina,uznat,vagon,valcha,valoun,vana,vandal,vanilka,varan,varhany,varovat,vcelku,vchod,vdova,vedro,vegetace,vejce,velbloud,veletrh,velitel,velmoc,velryba,venkov,veranda,verze,veselka,veskrze,vesnice,vespodu,vesta,veterina,veverka,vibrace,vichr,videohra,vidina,vidle,vila,vinice,viset,vitalita,vize,vizitka,vjezd,vklad,vkus,vlajka,vlak,vlasec,vlevo,vlhkost,vliv,vlnovka,vloupat,vnucovat,vnuk,voda,vodivost,vodoznak,vodstvo,vojensky,vojna,vojsko,volant,volba,volit,volno,voskovka,vozidlo,vozovna,vpravo,vrabec,vracet,vrah,vrata,vrba,vrcholek,vrhat,vrstva,vrtule,vsadit,vstoupit,vstup,vtip,vybavit,vybrat,vychovat,vydat,vydra,vyfotit,vyhledat,vyhnout,vyhodit,vyhradit,vyhubit,vyjasnit,vyjet,vyjmout,vyklopit,vykonat,vylekat,vymazat,vymezit,vymizet,vymyslet,vynechat,vynikat,vynutit,vypadat,vyplatit,vypravit,vypustit,vyrazit,vyrovnat,vyrvat,vyslovit,vysoko,vystavit,vysunout,vysypat,vytasit,vytesat,vytratit,vyvinout,vyvolat,vyvrhel,vyzdobit,vyznat,vzadu,vzbudit,vzchopit,vzdor,vzduch,vzdychat,vzestup,vzhledem,vzkaz,vzlykat,vznik,vzorek,vzpoura,vztah,vztek,xylofon,zabrat,zabydlet,zachovat,zadarmo,zadusit,zafoukat,zahltit,zahodit,zahrada,zahynout,zajatec,zajet,zajistit,zaklepat,zakoupit,zalepit,zamezit,zamotat,zamyslet,zanechat,zanikat,zaplatit,zapojit,zapsat,zarazit,zastavit,zasunout,zatajit,zatemnit,zatknout,zaujmout,zavalit,zavelet,zavinit,zavolat,zavrtat,zazvonit,zbavit,zbrusu,zbudovat,zbytek,zdaleka,zdarma,zdatnost,zdivo,zdobit,zdroj,zdvih,zdymadlo,zelenina,zeman,zemina,zeptat,zezadu,zezdola,zhatit,zhltnout,zhluboka,zhotovit,zhruba,zima,zimnice,zjemnit,zklamat,zkoumat,zkratka,zkumavka,zlato,zlehka,zloba,zlom,zlost,zlozvyk,zmapovat,zmar,zmatek,zmije,zmizet,zmocnit,zmodrat,zmrzlina,zmutovat,znak,znalost,znamenat,znovu,zobrazit,zotavit,zoubek,zoufale,zplodit,zpomalit,zprava,zprostit,zprudka,zprvu,zrada,zranit,zrcadlo,zrnitost,zrno,zrovna,zrychlit,zrzavost,zticha,ztratit,zubovina,zubr,zvednout,zvenku,zvesela,zvon,zvrat,zvukovod,zvyk" + .split(',') + } + // As per BIP39: + // > Only words containing all letters without diacritical marks. + // > (It was the hardest task, because in one third of all Czech letters + // > has diacritical marks.) + override fun matchMap(): Map { + return mapOf() + } + }; + + abstract val code: String // ISO 639-1: e.g. "en", "es", "fr" + abstract fun wordlist(): List + abstract fun matchMap(): Map + + fun matches(prefix: String): List { + val wordList = wordlist() + val matchMap = matchMap().toList() + val stdPrefix = matchMap.fold(prefix) { acc, pair -> + acc.replace(pair.first, pair.second) + } + return wordList.filter { word -> + val stdWord = matchMap.fold(word) { acc, pair -> + acc.replace(pair.first, pair.second) + } + stdWord.startsWith(stdPrefix, ignoreCase = true) + } + } + + companion object { + fun allCodes(): List { + return MnemonicLanguage.values().map { it.code } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/Parser.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/Parser.kt new file mode 100644 index 00000000..d4275596 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/Parser.kt @@ -0,0 +1,224 @@ +/* + * 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.utils + +import fr.acinq.bitcoin.* +import fr.acinq.bitcoin.utils.Either +import fr.acinq.bitcoin.utils.Try +import fr.acinq.lightning.payment.Bolt11Invoice +import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.wire.OfferTypes +import fr.acinq.phoenix.data.* +import fr.acinq.phoenix.data.lnurl.Lnurl +import io.ktor.http.* +import io.ktor.util.* + +object Parser { + + /** Order matters, as the prefixes are matched with startsWith. Longest prefixes should be at the beginning to avoid trimming only a part of the prefix. */ + val lightningPrefixes = listOf( + "phoenix:lightning://", + "phoenix:lightning:", + "lightning://", + "lightning:", + ) + + val bitcoinPrefixes = listOf( + "phoenix:bitcoin://", + "phoenix:bitcoin:", + "bitcoin://", + "bitcoin:" + ) + + val lnurlPrefixes = listOf( + "phoenix:lnurl://", + "phoenix:lnurl:", + "lnurl://", + "lnurl:", + ) + + fun removeExcessInput(input: String) = input.lines().firstOrNull { it.isNotBlank() }?.replace("\\u00A0", "")?.trim() ?: "" + + /** + * Remove the prefix from the input, if any. Trimming is done in a case-insensitive manner because often QR codes will + * use upper-case for the prefix, such as LIGHTNING:LNURL1... + */ + fun trimMatchingPrefix( + input: String, + prefixes: List + ): String { + val matchingPrefix = prefixes.firstOrNull { input.startsWith(it, ignoreCase = true) } + return if (matchingPrefix != null) { + input.drop(matchingPrefix.length) + } else { + input + } + } + + /** Reads a payment request after stripping prefixes. Return null if input is invalid. */ + fun readBolt11Invoice(input: String): Bolt11Invoice? { + return when (val res = Bolt11Invoice.read(trimMatchingPrefix(removeExcessInput(input), lightningPrefixes))) { + is Try.Success -> res.get() + is Try.Failure -> null + } + } + + fun readOffer(input: String): OfferTypes.Offer? { + val cleanInput = trimMatchingPrefix(removeExcessInput(input), bitcoinPrefixes + lightningPrefixes) + return when (val res = OfferTypes.Offer.decode(cleanInput)) { + is Try.Success -> res.get() + is Try.Failure -> { + null + } + } + } + + fun parseEmailLikeAddress(input: String): EmailLikeAddress? { + if (!input.contains("@", ignoreCase = true)) return null + + // Ignore excess input, including additional lines, and leading/trailing whitespace + val line = input.lines().firstOrNull { it.isNotBlank() }?.trim() + val token = line?.split("\\s+".toRegex())?.firstOrNull()?.let { + trimMatchingPrefix(it, bitcoinPrefixes + lightningPrefixes + lnurlPrefixes) + } + + if (token.isNullOrBlank()) return null + + val components = token.split("@") + if (components.size != 2) return null + + val username = components[0].lowercase() + .replace("%E2%82%BF", "₿", ignoreCase = true) // the Bitcoin char may be url-encoded + val domain = components[1] + + if (username.isBlank() || domain.isBlank()) return null + + return if (username.startsWith("₿")) { + EmailLikeAddress.Bip353(token, username.dropWhile { it == '₿' }, domain) + } else { + EmailLikeAddress.UnknownType(token, username, domain) + } + } + + /** + * Parses an input and returns a bip-21 [BitcoinUri] if it is valid, or a typed error otherwise. + * + * @param chain the chain this parser expects the address to be valid on. + * @param input can range from a basic bitcoin address to a sophisticated Bitcoin URI with a prefix and parameters. + */ + fun parseBip21Uri( + chain: Chain, + input: String + ): Either { + val cleanInput = removeExcessInput(input) + val url = try { + Url(cleanInput) + } catch (e: Exception) { + return Either.Left(BitcoinUriError.InvalidUri) + } + // -- get address + // The input might look like: bitcoin:tb1qla78tll0eua3l5f4nvfq3tx58u35yc3m44flfu?time=1618931109&exp=604800 + // We want to parse the parameters and the address. However the Url api lacks a simple property to extract an address. + val address = trimMatchingPrefix(cleanInput, bitcoinPrefixes).substringBefore("?") + + // -- read parameters + val requiredParams = url.parameters.entries().filter { it.key.startsWith("req-") }.map { it.key to it.value.joinToString(";") } + if (requiredParams.isNotEmpty()) { + return Either.Left(BitcoinUriError.UnhandledRequiredParams(requiredParams)) + } + + val amountSplit = url.parameters["amount"]?.trim()?.split(".", ignoreCase = true, limit = 2) + val btcPart = amountSplit?.first() + val satPart = amountSplit?.last()?.take(8)?.padEnd(8, '0') + val amount = when { + btcPart != null && satPart != null -> btcPart + satPart + btcPart != null && satPart == null -> btcPart + "00000000" + btcPart == null && satPart != null -> satPart + else -> null + }?.toLongOrNull()?.takeIf { it > 0L && it <= 21e14 }?.sat + + val label = url.parameters["label"] + val message = url.parameters["message"] + val lightning = url.parameters["lightning"]?.let { + when (val res = Bolt11Invoice.read(it)) { + is Try.Success -> { + val invoiceChain = res.result.chain + if (invoiceChain != chain) { + if (address.isBlank()) { + return Either.Left(BitcoinUriError.InvalidScript(BitcoinError.ChainHashMismatch)) + } else { + null + } + } else { + res.result + } + } + is Try.Failure -> null + } + } + val offer = url.parameters["lno"]?.let { + when (val res = OfferTypes.Offer.decode(it)) { + is Try.Success -> { + if (!res.result.chains.contains(chain.chainHash)) { + if (address.isBlank()) { + return Either.Left(BitcoinUriError.InvalidScript(BitcoinError.ChainHashMismatch)) + } else { + null + } + } else { + res.result + } + } + is Try.Failure -> null + } + } + val otherParams = ParametersBuilder().apply { + appendAll(url.parameters.filter { entry, _ -> + !listOf("amount", "label", "message", "lightning", "lno").contains(entry) + }) + }.build() + + val scriptParse = address.takeIf { it.isNotBlank() }?.let { + Bitcoin.addressToPublicKeyScript(chain.chainHash, address) + } + return when (scriptParse) { + is Either.Left -> Either.Left(BitcoinUriError.InvalidScript(scriptParse.left)) + else -> Either.Right( + BitcoinUri( + chain = chain, address = address, script = scriptParse?.right?.let { Script.write(it) }?.byteVector(), label = label, + message = message, amount = amount, paymentRequest = lightning, offer = offer, ignoredParams = otherParams, + ) + ) + } + } + + /** Transforms a bitcoin address into a public key script if valid, otherwise returns null. */ + fun addressToPublicKeyScriptOrNull(chain: Chain, address: String): ByteVector? { + return parseBip21Uri(chain, address).right?.script + } +} + +sealed class EmailLikeAddress { + abstract val source: String + abstract val username: String + abstract val domain: String + data class UnknownType(override val source: String, override val username: String, override val domain: String) : EmailLikeAddress() + data class LnurlBased(override val source: String, override val username: String, override val domain: String) : EmailLikeAddress() { + val url = Lnurl.Request(Url("https://$domain/.well-known/lnurlp/$username"), tag = Lnurl.Tag.Pay) + } + data class Bip353(override val source: String, override val username: String, override val domain: String) : EmailLikeAddress() +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/PlatformContext.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/PlatformContext.kt new file mode 100644 index 00000000..c034b0c4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/PlatformContext.kt @@ -0,0 +1,25 @@ +/* + * 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.utils + + +expect class PlatformContext + +expect fun getApplicationFilesDirectoryPath(ctx: PlatformContext): String +expect fun getDatabaseFilesDirectoryPath(ctx: PlatformContext): String? +expect fun getApplicationCacheDirectoryPath(ctx: PlatformContext): String +expect fun getTemporaryDirectoryPath(ctx: PlatformContext): String diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/channels/ChannelsImportHelper.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/channels/ChannelsImportHelper.kt new file mode 100644 index 00000000..fe8a69cc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/channels/ChannelsImportHelper.kt @@ -0,0 +1,78 @@ +package fr.acinq.phoenix.utils.channels + +import fr.acinq.bitcoin.ByteVector +import fr.acinq.lightning.channel.states.PersistedChannelState +import fr.acinq.lightning.serialization.channel.Encryption.from +import fr.acinq.lightning.serialization.channel.Serialization +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.lightning.logging.error +import fr.acinq.lightning.logging.info +import fr.acinq.phoenix.db.SqliteChannelsDb +import fr.acinq.secp256k1.Hex +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first + + +object ChannelsImportHelper { + +// suspend fun doImportChannels( +// data: String, +// biz: PhoenixBusiness, +// ): ChannelsImportResult { +// +// val loggerFactory = biz.loggerFactory +// val log = loggerFactory.newLogger(this::class) +// try { +// +// log.info { "initiating channels-data import" } +// +// val nodeParams = biz.nodeParamsManager.nodeParams.filterNotNull().first() +// val peer = biz.peerManager.getPeer() +// +// val encryptedChannelData = try { +// EncryptedChannelData(ByteVector(Hex.decode(data))) +// } catch(e: Exception) { +// log.error(e) { "failed to deserialize data blob" } +// return ChannelsImportResult.Failure.MalformedData +// } +// +// return PersistedChannelState +// .from(nodeParams.nodePrivateKey, encryptedChannelData) +// .fold( +// onFailure = { +// log.error(it) { "failed to decrypt channel state" } +// ChannelsImportResult.Failure.DecryptionError +// }, +// onSuccess = { +// when (it) { +// is Serialization.DeserializationResult.Success -> { +// log.info { "successfully imported channel=${it.state.channelId}" } +// peer.db.channels.addOrUpdateChannel(it.state) +// val channel = (peer.db.channels as? SqliteChannelsDb)?.getChannel(it.state.channelId) +// log.info { "channel added/updated to database, is_closed=${channel?.third}" } +// ChannelsImportResult.Success(it.state) +// } +// is Serialization.DeserializationResult.UnknownVersion -> { +// log.error { "cannot use channel state: unknown version=${it.version}" } +// ChannelsImportResult.Failure.UnknownVersion(it.version) +// } +// } +// } +// ) +// +// } catch (e: Exception) { +// log.error(e) { "error when importing channels" } +// return ChannelsImportResult.Failure.Generic(e) +// } +// } +} + +sealed class ChannelsImportResult { + data class Success(val channel: PersistedChannelState) : ChannelsImportResult() + sealed class Failure : ChannelsImportResult() { + data class Generic(val error: Throwable) : Failure() + data class UnknownVersion(val version: Int) : Failure() + object MalformedData : Failure() + object DecryptionError : Failure() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/channels/SpendChannelAddressHelper.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/channels/SpendChannelAddressHelper.kt new file mode 100644 index 00000000..2ddbc148 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/channels/SpendChannelAddressHelper.kt @@ -0,0 +1,144 @@ +/* + * 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.channels + +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector64 +import fr.acinq.bitcoin.Crypto +import fr.acinq.bitcoin.PublicKey +import fr.acinq.bitcoin.Satoshi +import fr.acinq.bitcoin.SigHash +import fr.acinq.bitcoin.SigVersion +import fr.acinq.bitcoin.Script +import fr.acinq.bitcoin.Transaction +import fr.acinq.bitcoin.TxId +import fr.acinq.bitcoin.byteVector +import fr.acinq.lightning.channel.states.ChannelStateWithCommitments +import fr.acinq.lightning.channel.states.PersistedChannelState +import fr.acinq.lightning.logging.error +import fr.acinq.lightning.serialization.channel.Encryption.from +import fr.acinq.lightning.serialization.channel.Serialization +import fr.acinq.lightning.transactions.Scripts +import fr.acinq.lightning.transactions.Transactions +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.secp256k1.Hex +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first + +object SpendChannelAddressHelper { + + /** + * This method signs a transaction that spends the outpoint of a channel. The goal is to recover funds that were accidentally sent + * to a channel address (sometimes users think they can reverse a splice-out this way). + * + * @param channelData the encrypted channel data, that need to be decrypted to get the channel's funding key path + * @param remoteFundingPubkey LSP funding pubkey + * @param unsignedTx the refund transaction generated by the LSP + * + * @return a pair of (public key, signature) + */ +// suspend fun spendFromChannelAddress( +// business: PhoenixBusiness, +// amount: Satoshi, +// fundingTxIndex: Long, +// channelData: String, +// remoteFundingPubkey: String, +// unsignedTx: String +// ): SpendChannelAddressResult { +// val loggerFactory = business.loggerFactory +// val log = loggerFactory.newLogger(this::class) +// val peer = business.peerManager.getPeer() +// val nodeParams = business.nodeParamsManager.nodeParams.filterNotNull().first() +// +// val deserializedChannelData = try { +// EncryptedChannelData(ByteVector(Hex.decode(channelData))) +// } catch(e: Exception) { +// log.error(e) { "failed to deserialize channels-data blob" } +// return SpendChannelAddressResult.Failure.ChannelDataMalformed +// } +// +// val tx = try { +// Transaction.read(unsignedTx) +// } catch (e: Exception) { +// log.error(e) { "failed to read transaction hex" } +// return SpendChannelAddressResult.Failure.TransactionMalformed(e.message ?: e::class.simpleName.toString()) +// } +// +// val pubkey = try { +// PublicKey.fromHex(remoteFundingPubkey) +// } catch (e: Exception) { +// log.error(e) { "failed to read remote-funding-pubkey" } +// return SpendChannelAddressResult.Failure.RemoteFundingPubkeyMalformed(e.message ?: e::class.simpleName.toString()) +// } +// +// try { +// val channelKeyPath = PersistedChannelState.from(nodeParams.nodePrivateKey, deserializedChannelData) +// .fold( +// onFailure = { +// log.error { "failed to decrypt channel" } +// return SpendChannelAddressResult.Failure.ChannelDataDecryption +// }, +// onSuccess = { +// when (it) { +// is Serialization.DeserializationResult.Success -> { +// when (val s = it.state) { +// is ChannelStateWithCommitments -> s.commitments.params.localParams.fundingKeyPath +// else -> { +// log.error { "unhandled channel data state: ${it::class.simpleName}" } +// return SpendChannelAddressResult.Failure.ChannelDataUnhandledState(it::class.simpleName) +// } +// } +// } +// is Serialization.DeserializationResult.UnknownVersion -> { +// log.error { "unhandled channel data version: ${it.version}" } +// return SpendChannelAddressResult.Failure.ChannelDataUnhandledVersion(it.version) +// } +// } +// } +// ) +// +// val channelKeys = peer.nodeParams.keyManager.channelKeys(channelKeyPath) +// val localFundingKey = channelKeys.fundingKey(fundingTxIndex) +// val fundingScript = Scripts.multiSig2of2(localFundingKey.publicKey(), pubkey) +// +// val sig = Transactions.sign(tx = tx, inputIndex = 0, Script.write(fundingScript), amount, localFundingKey) +// val signedData = tx.hashForSigning(0, Script.write(fundingScript), SigHash.SIGHASH_ALL, amount, SigVersion.SIGVERSION_WITNESS_V0) +// return if (!Crypto.verifySignature(signedData, sig, localFundingKey.publicKey())) { +// SpendChannelAddressResult.Failure.InvalidSig(tx.txid, localFundingKey.publicKey(), Script.write(fundingScript).byteVector(), sig) +// } else { +// SpendChannelAddressResult.Success(tx.txid, localFundingKey.publicKey(), Script.write(fundingScript).byteVector(), sig) +// } +// } catch (e: Exception) { +// log.error { "error when spending from channel address: ${e.message}" } +// return SpendChannelAddressResult.Failure.Generic(e) +// } +// } +} + +sealed class SpendChannelAddressResult { + data class Success(val txId: TxId, val publicKey: PublicKey, val fundingScript: ByteVector, val signature: ByteVector64) : SpendChannelAddressResult() + sealed class Failure : SpendChannelAddressResult() { + data class Generic(val error: Throwable) : Failure() + data object ChannelDataMalformed : Failure() + data object ChannelDataDecryption : Failure() + data class ChannelDataUnhandledState(val stateName: String?) : Failure() + data class ChannelDataUnhandledVersion(val version: Int) : Failure() + data class TransactionMalformed(val details: String) : Failure() + data class RemoteFundingPubkeyMalformed(val details: String) : Failure() + data class InvalidSig(val txId: TxId, val publicKey: PublicKey, val fundingScript: ByteVector, val signature: ByteVector64) : Failure() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/converters/AmountConverter.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/converters/AmountConverter.kt new file mode 100644 index 00000000..cf2a75f8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/converters/AmountConverter.kt @@ -0,0 +1,137 @@ +/* + * 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 androidx.compose.runtime.Composable +import co.touchlab.kermit.Logger +import com.ionspin.kotlin.bignum.decimal.toBigDecimal +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.toMilliSatoshi +import fr.acinq.phoenix.LocalExchangeRatesMap +import fr.acinq.phoenix.data.BitcoinUnit +import fr.acinq.phoenix.data.CurrencyUnit +import fr.acinq.phoenix.data.ExchangeRate +import fr.acinq.phoenix.data.FiatCurrency + + +sealed class AmountConversionResult { + sealed class Error : AmountConversionResult() { + data object InvalidInput : Error() + data object AmountTooLarge: Error() + data object AmountNegative: Error() + data object RateUnavailable: Error() + } +} + +// wrapper for ComplexAmount +data class FiatAmount(val value: Double, val currency: FiatCurrency, val exchangeRate: ExchangeRate) + +data class ComplexAmount(val amount: MilliSatoshi, val fiat: FiatAmount?): AmountConversionResult() + +object AmountConverter { + private val log = Logger.withTag("AmountConverter") + + /** + * This methods converts a string [input] expressed in [unit] (can be fiat or bitcoin) into a standardised + * [ComplexAmount] object, using the provided fiat rate for conversion. + * + * Returns an [AmountConversionResult.Error] if conversion cannot be done. + */ + fun convertToComplexAmount( + input: String?, + unit: CurrencyUnit, + rate: ExchangeRate.BitcoinPriceRate?, + ): AmountConversionResult? { + log.d("amount input update [ amount=$input unit=$unit with rate=$rate ]") + + if (input.isNullOrBlank()) { + return null + } + + val amount = try { + // note: DecimalFormat.parse works somewhat, but can be confused when the input contains a mix of "." and "," and returns 0 + // instead we used String.toDouble, which expects "." as decimal separator + input.replace(",", ".").toDouble() + } catch (e: Exception) { + log.d("could not parse input=$input: ", e) + return AmountConversionResult.Error.InvalidInput + } + + return when (unit) { + is FiatCurrency -> { + if (rate == null) { + log.w("cannot convert fiat amount to bitcoin with a null rate") + AmountConversionResult.Error.RateUnavailable + } else { + // convert fiat amount to millisat, but truncate the msat part to avoid issues with + // services/wallets that don't understand millisats. We only do this when converting + // from fiat. If amount is in btc, we use the real value entered by the user. + val msat = amount.toMilliSatoshi(rate.price).truncateToSatoshi().toMilliSatoshi() + if (msat.toUnit(BitcoinUnit.Btc) > 21e6) { + AmountConversionResult.Error.AmountTooLarge + } else if (msat < 0.msat) { + AmountConversionResult.Error.AmountNegative + } else { + ComplexAmount(msat, FiatAmount(amount, unit, rate)) + } + } + } + is BitcoinUnit -> { + val msat = amount.toMilliSatoshi(unit) + if (msat.toUnit(BitcoinUnit.Btc) > 21e6) { + AmountConversionResult.Error.AmountTooLarge + } else if (msat < 0.msat) { + AmountConversionResult.Error.AmountNegative + } else if (rate == null) { + // conversion is not possible but that should not stop a payment from happening + ComplexAmount(amount = msat, fiat = null) + } else { + val fiat = msat.toFiat(rate.price) + ComplexAmount(amount = msat, fiat = FiatAmount(fiat, rate.fiatCurrency, rate)) + } + } + else -> { + null + } + } + } + + /** Converts this [Double] amount to [MilliSatoshi], assuming that this amount is denominated in fiat. */ + fun Double.toMilliSatoshi(fiatRate: Double): MilliSatoshi = (this / fiatRate).toMilliSatoshi(BitcoinUnit.Btc) + @Composable + fun Double.toMilliSatoshi(fiat: FiatCurrency): MilliSatoshi? = LocalExchangeRatesMap.current[fiat]?.let { this.toMilliSatoshi(it.price) } + + /** Converts this [Double] amount to [MilliSatoshi], assuming that this amount is denominated in the given Bitcoin [unit]. */ + fun Double.toMilliSatoshi(unit: BitcoinUnit): MilliSatoshi = when (unit) { + BitcoinUnit.Sat -> this.toBigDecimal().moveDecimalPoint(3).longValue(false).msat + BitcoinUnit.Bit -> this.toBigDecimal().moveDecimalPoint(5).longValue(false).msat + BitcoinUnit.MBtc -> this.toBigDecimal().moveDecimalPoint(8).longValue(false).msat + BitcoinUnit.Btc -> this.toBigDecimal().moveDecimalPoint(11).longValue(false).msat + } + + /** Converts [MilliSatoshi] to another Bitcoin unit. */ + fun MilliSatoshi.toUnit(unit: BitcoinUnit): Double = when (unit) { + BitcoinUnit.Sat -> this.msat.toBigDecimal().moveDecimalPoint(-3).doubleValue() + BitcoinUnit.Bit -> this.msat.toBigDecimal().moveDecimalPoint(-5).doubleValue() + BitcoinUnit.MBtc -> this.msat.toBigDecimal().moveDecimalPoint(-8).doubleValue() + BitcoinUnit.Btc -> this.msat.toBigDecimal().moveDecimalPoint(-11).doubleValue() + } + + /** Converts [MilliSatoshi] to a fiat amount. */ + fun MilliSatoshi.toFiat(rate: Double): Double = this.toUnit(BitcoinUnit.Btc) * rate +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/converters/AmountFormatter.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/converters/AmountFormatter.kt new file mode 100644 index 00000000..cd67e6f2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/converters/AmountFormatter.kt @@ -0,0 +1,118 @@ +/* + * 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.converters + + +import fr.acinq.bitcoin.Satoshi +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.utils.toMilliSatoshi +import fr.acinq.phoenix.data.* +import fr.acinq.phoenix.utils.converters.AmountConverter.toFiat +import fr.acinq.phoenix.utils.converters.AmountConverter.toUnit + +enum class MSatDisplayPolicy { + HIDE, SHOW, SHOW_IF_ZERO_SATS +} + +object AmountFormatter { + +// private var SAT_FORMAT_WITH_MILLIS: NumberFormat = DecimalFormat("###,###,###,##0.###") +// private var SAT_FORMAT: NumberFormat = DecimalFormat("###,###,###,##0").apply { roundingMode = RoundingMode.DOWN } +// private var BIT_FORMAT_WITH_MILLIS: NumberFormat = DecimalFormat("###,###,###,##0.00###") +// private var BIT_FORMAT: NumberFormat = DecimalFormat("###,###,###,##0.00").apply { roundingMode = RoundingMode.DOWN } +// private var MBTC_FORMAT_WITH_MILLIS: NumberFormat = DecimalFormat("###,###,###,##0.00000###") +// private var MBTC_FORMAT: NumberFormat = DecimalFormat("###,###,###,##0.00000").apply { roundingMode = RoundingMode.DOWN } +// private var BTC_FORMAT_WITH_MILLIS: NumberFormat = DecimalFormat("###,###,###,##0.00000000###") +// private var BTC_FORMAT: NumberFormat = DecimalFormat("###,###,###,##0.00000000").apply { roundingMode = RoundingMode.DOWN } +// +// /** Fiat format has always 2 decimals, with rounding. */ +// var FIAT_FORMAT: NumberFormat = NumberFormat.getInstance().apply { +// minimumFractionDigits = 2 +// maximumFractionDigits = 2 +// roundingMode = RoundingMode.CEILING // prevent converting very small bitcoin amounts to 0 in fiat +// } +// /** Fiat format but at most 2 decimals and without the thousand grouping. Useful when field is not read-only, where grouping would cause issues. */ +// var FIAT_FORMAT_WRITABLE: NumberFormat = NumberFormat.getInstance().apply { +// minimumFractionDigits = 0 +// maximumFractionDigits = 2 +// roundingMode = RoundingMode.CEILING +// isGroupingUsed = false +// } + +// TODO: private fun getCoinFormat(unit: BitcoinUnit, withMillis: Boolean) = when { +// unit == BitcoinUnit.Sat && withMillis-> SAT_FORMAT_WITH_MILLIS +// unit == BitcoinUnit.Sat -> SAT_FORMAT +// unit == BitcoinUnit.Bit && withMillis-> BIT_FORMAT_WITH_MILLIS +// unit == BitcoinUnit.Bit -> BIT_FORMAT +// unit == BitcoinUnit.MBtc && withMillis -> MBTC_FORMAT_WITH_MILLIS +// unit == BitcoinUnit.MBtc -> MBTC_FORMAT +// unit == BitcoinUnit.Btc && withMillis -> BTC_FORMAT_WITH_MILLIS +// else -> BTC_FORMAT +// } + + /** Format the [Double] as a String using [DecimalFormat]. */ + fun Double?.toPlainString(limitDecimal: Boolean = false): String = this?.takeIf { it > 0 }?.run { + return this.toString() +// TODO: val df = if (limitDecimal) DecimalFormat("0.00") else DecimalFormat("0.########") +// df.format(this) + } ?: "" + + fun Double?.toPrettyString( + unit: CurrencyUnit, + withUnit: Boolean = false, + mSatDisplayPolicy: MSatDisplayPolicy = MSatDisplayPolicy.HIDE, + ): String { + return this.toString() +// TODO: val amount = this?.let { +// when { +// unit == BitcoinUnit.Sat && it < 1.0 && mSatDisplayPolicy == MSatDisplayPolicy.SHOW_IF_ZERO_SATS -> { +// SAT_FORMAT_WITH_MILLIS.format(it) +// } +// unit is BitcoinUnit -> { +// getCoinFormat(unit, withMillis = mSatDisplayPolicy == MSatDisplayPolicy.SHOW).format(it) +// } +// unit is FiatCurrency -> { +// it.takeIf { it >= 0 }?.let { FIAT_FORMAT.format(it) } +// } +// else -> "?!" +// } +// } ?: "N/A" +// return if (withUnit) { +// "$amount ${unit.displayCode}" +// } else { +// amount +// } + } + + fun MilliSatoshi.toPrettyStringWithFallback(unit: CurrencyUnit, rate: ExchangeRate.BitcoinPriceRate? = null, withUnit: Boolean = false, mSatDisplayPolicy: MSatDisplayPolicy = MSatDisplayPolicy.HIDE): String { + return if (rate == null) { + toPrettyString(BitcoinUnit.Sat, null, withUnit, mSatDisplayPolicy) + } else { + toPrettyString(unit, rate, withUnit, mSatDisplayPolicy) + } + } + + fun MilliSatoshi.toPrettyString(unit: CurrencyUnit, rate: ExchangeRate.BitcoinPriceRate? = null, withUnit: Boolean = false, mSatDisplayPolicy: MSatDisplayPolicy = MSatDisplayPolicy.HIDE): String = when { + unit is BitcoinUnit -> this.toUnit(unit).toPrettyString(unit, withUnit, mSatDisplayPolicy) + unit is FiatCurrency && rate != null -> this.toFiat(rate.price).toPrettyString(unit, withUnit) + else -> "?!" + } + + fun Satoshi.toPrettyString(unit: CurrencyUnit, rate: ExchangeRate.BitcoinPriceRate? = null, withUnit: Boolean = false, mSatDisplayPolicy: MSatDisplayPolicy = MSatDisplayPolicy.HIDE): String { + return this.toMilliSatoshi().toPrettyString(unit, rate, withUnit, mSatDisplayPolicy) + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/ChainExtensions.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/ChainExtensions.kt new file mode 100644 index 00000000..fc496516 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/ChainExtensions.kt @@ -0,0 +1,16 @@ +package fr.acinq.phoenix.utils.extensions + +import fr.acinq.bitcoin.Chain + +/** + * Value used by Phoenix for naming files relative to the [Chain]. + * Specifically, testnet3 name must be "testnet", for historical reasons. + */ +val Chain.phoenixName: String + get() = when (this) { + Chain.Regtest -> "regtest" + Chain.Signet -> "signet" + Chain.Testnet3 -> "testnet" + Chain.Testnet4 -> "testnet4" + Chain.Mainnet -> "mainnet" + } diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/ChannelExtensions.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/ChannelExtensions.kt new file mode 100644 index 00000000..2843d013 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/ChannelExtensions.kt @@ -0,0 +1,76 @@ +/* + * 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.utils.extensions + +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.channel.* +import fr.acinq.lightning.channel.states.* +import fr.acinq.lightning.utils.msat + + +fun ChannelState.isTerminated(): Boolean { + return when (this) { + is Syncing -> state.isTerminated() + is Offline -> state.isTerminated() + is Closing, is Closed, is Aborted -> true + else -> false + } +} + +fun ChannelState.isBeingCreated(): Boolean { + return when (this) { + is Syncing -> state.isBeingCreated() + is Offline -> state.isBeingCreated() + is WaitForAcceptChannel, + is WaitForChannelReady, + is WaitForFundingConfirmed, + is WaitForFundingCreated, + is WaitForFundingSigned, + is WaitForInit, + is WaitForOpenChannel, + is WaitForRemotePublishFutureCommitment -> true + else -> false + } +} + +/** + * The balance that we can use to spend. Uses the [Commitment.availableBalanceForSend] method under the hood. + * For some states, this balance is forced to null. + */ +fun ChannelState.localBalance(): MilliSatoshi? { + return when (this) { + // if offline or syncing, check the underlying state. + is Offline -> state.localBalance() + is Syncing -> state.localBalance() + // for some states the balance should be 0 + is Closing -> 0.msat + is Closed -> 0.msat + is Aborted -> null + // balance is unknown + is Negotiating -> null + is WaitForAcceptChannel -> null + is WaitForChannelReady -> null + is WaitForFundingConfirmed -> null + is WaitForFundingCreated -> null + is WaitForFundingSigned -> null + is WaitForInit -> null + is WaitForOpenChannel -> null + is WaitForRemotePublishFutureCommitment -> null + // regular case + is ChannelStateWithCommitments -> commitments.availableBalanceForSend() + } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/ConnectionExtensions.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/ConnectionExtensions.kt new file mode 100644 index 00000000..41952884 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/ConnectionExtensions.kt @@ -0,0 +1,33 @@ +/* + * 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.utils.extensions + +import fr.acinq.lightning.NodeUri +import fr.acinq.lightning.utils.Connection +import fr.acinq.lightning.utils.ServerAddress + +operator fun Connection?.plus(other: Connection?) : Connection = + when { + this == other && this != null -> this + this == Connection.ESTABLISHING || other == Connection.ESTABLISHING -> Connection.ESTABLISHING + this is Connection.CLOSED -> this + other is Connection.CLOSED -> other + else -> this ?: other ?: error("cannot combine connections [$this + $other]") + } + +val ServerAddress.isOnion get() = this.host.endsWith(".onion") +val NodeUri.isOnion get() = this.host.endsWith(".onion") \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/MiscExtensions.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/MiscExtensions.kt new file mode 100644 index 00000000..fa48efb9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/MiscExtensions.kt @@ -0,0 +1,31 @@ +/* + * 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 fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.io.ByteArrayOutput +import fr.acinq.lightning.serialization.OutputExtensions.writeUuid +import fr.acinq.lightning.utils.UUID + +fun ByteVector32.deriveUUID(): UUID = UUID.fromBytes(this.take(16).toByteArray()) + +// TODO: use standard Uuid once migrated to kotlin 2 +fun UUID.toByteArray() = + ByteArrayOutput().run { + writeUuid(this@toByteArray) + toByteArray() + } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/PaymentExtensions.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/PaymentExtensions.kt new file mode 100644 index 00000000..3a238dd5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/PaymentExtensions.kt @@ -0,0 +1,76 @@ +/* + * 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. + */ + +@file:Suppress("DEPRECATION") + +package fr.acinq.phoenix.utils.extensions + +import fr.acinq.lightning.db.AutomaticLiquidityPurchasePayment +import fr.acinq.lightning.db.Bolt12IncomingPayment +import fr.acinq.lightning.db.IncomingPayment +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.OnChainOutgoingPayment +import fr.acinq.lightning.db.SpliceInIncomingPayment +import fr.acinq.lightning.db.WalletPayment +import fr.acinq.lightning.payment.OfferPaymentMetadata +import fr.acinq.lightning.wire.OfferTypes + +enum class WalletPaymentState { SuccessOnChain, SuccessOffChain, PendingOnChain, PendingOffChain, Failure } + +fun WalletPayment.state(): WalletPaymentState = when (this) { + is ManualLiquidityPurchasePayment -> when (lockedAt) { + null -> WalletPaymentState.PendingOnChain + else -> WalletPaymentState.SuccessOnChain + } + is AutomaticLiquidityPurchasePayment -> when (lockedAt) { + null -> WalletPaymentState.PendingOnChain + else -> WalletPaymentState.SuccessOnChain + } + is OnChainOutgoingPayment -> when (confirmedAt) { + null -> WalletPaymentState.PendingOnChain + else -> WalletPaymentState.SuccessOnChain + } + is LightningOutgoingPayment -> when (status) { + is LightningOutgoingPayment.Status.Pending -> WalletPaymentState.PendingOffChain + is LightningOutgoingPayment.Status.Succeeded -> WalletPaymentState.SuccessOffChain + is LightningOutgoingPayment.Status.Failed -> WalletPaymentState.Failure + } + is LightningIncomingPayment, is LegacyPayToOpenIncomingPayment -> when (completedAt) { + null -> WalletPaymentState.PendingOffChain + else -> WalletPaymentState.SuccessOffChain + } + is SpliceInIncomingPayment, is NewChannelIncomingPayment, is LegacySwapInIncomingPayment -> when (completedAt) { + null -> WalletPaymentState.PendingOnChain + else -> WalletPaymentState.SuccessOnChain + } +} + +fun WalletPayment.errorMessage(): String? = when (this) { + is OnChainOutgoingPayment -> null + is LightningOutgoingPayment -> when (val s = status) { + is LightningOutgoingPayment.Status.Failed -> s.reason.toString() + else -> null + } + is IncomingPayment -> null +} + +fun WalletPayment.incomingOfferMetadata(): OfferPaymentMetadata.V1? = (this as? Bolt12IncomingPayment)?.metadata as? OfferPaymentMetadata.V1 +fun WalletPayment.outgoingInvoiceRequest(): OfferTypes.InvoiceRequest? = ((this as? LightningOutgoingPayment)?.details as? LightningOutgoingPayment.Details.Blinded)?.paymentRequest?.invoiceRequest diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/PaymentRequestExtensions.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/PaymentRequestExtensions.kt new file mode 100644 index 00000000..c40159ec --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/PaymentRequestExtensions.kt @@ -0,0 +1,43 @@ +/* + * 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.utils.extensions + +import fr.acinq.lightning.Feature +import fr.acinq.lightning.payment.Bolt11Invoice +import fr.acinq.lightning.payment.Bolt12Invoice +import fr.acinq.lightning.payment.OfferPaymentMetadata +import fr.acinq.lightning.payment.PaymentRequest + +fun Bolt11Invoice.isAmountlessTrampoline() = this.amount == null // TODO: && this.features.hasFeature(Feature.TrampolinePayment) + +/** + * In Objective-C, the function name `description()` is already in use (part of NSObject). + * So we need to alias it. + */ +fun Bolt11Invoice.desc(): String? = this.description + +val PaymentRequest.desc: String? + get() = when (this) { + is Bolt11Invoice -> this.description + is Bolt12Invoice -> this.description + } + +val OfferPaymentMetadata.payerNote: String? + get() = when { + this is OfferPaymentMetadata.V1 -> this.payerNote + else -> null + } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.kt new file mode 100644 index 00000000..a6f151ec --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.kt @@ -0,0 +1,27 @@ +/* + * 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 fr.acinq.phoenix.data.DecryptSeedResult +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + + +expect inline fun gracefulSingleSeedDecryption(action: () -> DecryptSeedResult): DecryptSeedResult + +expect inline fun gracefulMultiSeedDecryption(action: () -> DecryptSeedResult): DecryptSeedResult \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/WalletStateExtensions.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/WalletStateExtensions.kt new file mode 100644 index 00000000..dc5b1d68 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/WalletStateExtensions.kt @@ -0,0 +1,50 @@ +/* + * 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.utils.extensions + +import fr.acinq.lightning.SwapInParams +import fr.acinq.lightning.blockchain.electrum.FinalWallet +import fr.acinq.lightning.blockchain.electrum.WalletState +import fr.acinq.lightning.utils.getValue +import kotlin.math.ceil + +/** Number of confirmation during which a utxo is locked between the swap window closing and the refund delay. */ +val SwapInParams.gracePeriod: Int + get() = refundDelay - maxConfirmations +val SwapInParams.gracePeriodInDays: Int + get() = ceil( gracePeriod.toDouble() / 144).toInt() +val SwapInParams.maxConfirmationsInDays: Int + get() = ceil(maxConfirmations.toDouble() / 144).toInt() +val SwapInParams.refundDelayInDays: Int + get() = ceil(refundDelay.toDouble() / 144).toInt() + +/** Returns the block count after which a utxo will NOT be swappable anymore, according to the wallet's swap params. */ +fun WalletState.WalletWithConfirmations.timeoutIn(utxo: WalletState.Utxo): Int { + return (swapInParams.maxConfirmations - confirmations(utxo)).coerceAtLeast(0) +} + +/** A map of deeply confirmed utxos to their expiry, according to the wallet's swap params. */ +val WalletState.WalletWithConfirmations.deeplyConfirmedToExpiry: List> + get() = deeplyConfirmed.map { it to timeoutIn(it) } + +/** A map of deeply confirmed utxos to their expiry, according to the wallet's swap params. */ +val WalletState.WalletWithConfirmations.nextTimeout: Pair? + get() = deeplyConfirmedToExpiry.minByOrNull { it.second } + +/** List of all confirmed utxos. */ +val WalletState.WalletWithConfirmations.confirmed + get() = this.all.filter { it.blockHeight > 0L } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/logger/LoggerConfig.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/logger/LoggerConfig.kt new file mode 100644 index 00000000..dc75c5e0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/logger/LoggerConfig.kt @@ -0,0 +1,40 @@ +/* + * 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.logger + +import co.touchlab.kermit.LogWriter +import co.touchlab.kermit.LoggerConfig +import co.touchlab.kermit.Severity +import co.touchlab.kermit.StaticConfig +import fr.acinq.phoenix.utils.PlatformContext + +/** + * Contains a logging configuration. + * Not an object, because the platform context is required for the log writers, which are platform dependent. + * + * Would have used [StaticConfig] but it cannot be extended. + */ +data class PhoenixLoggerConfig(private val platformContext: PlatformContext): LoggerConfig { + override val logWriterList: List = phoenixLogWriters(platformContext) + override val minSeverity: Severity = Severity.Debug +} + +/** + * Factory function to return a default list of LogWriters for each platform. The LogWriter is targeted at local development. + * For production implementations, you may need to directly initialize your Logger config. + */ +expect fun phoenixLogWriters(ctx: PlatformContext): List \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/migrations/IosMigrationHelper.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/migrations/IosMigrationHelper.kt new file mode 100644 index 00000000..2ecfc5db --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/migrations/IosMigrationHelper.kt @@ -0,0 +1,169 @@ +/* + * 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.utils.migrations + +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.TxId +import fr.acinq.bitcoin.byteVector +import fr.acinq.lightning.Feature +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.blockchain.fee.FeeratePerKw +import fr.acinq.lightning.channel.ChannelCommand +import fr.acinq.lightning.channel.states.* +import fr.acinq.lightning.io.WrappedChannelCommand +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sum +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.data.LocalChannelInfo +import fr.acinq.phoenix.utils.Parser +import fr.acinq.phoenix.utils.extensions.isBeingCreated +import fr.acinq.lightning.logging.info +import fr.acinq.lightning.logging.warning +import fr.acinq.phoenix.managers.phoenixSwapInWallet +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +object IosMigrationHelper { + + /** + * We should migrate channels if there is at least 1 active channels that is not dual-funding. + */ + fun shouldMigrateChannels(channels: List): Boolean { + return channels.any { + when (val state = it.state) { + is Offline -> state.state.isLegacy() + is Syncing -> state.state.isLegacy() + else -> state.isLegacy() + } + } + } + + private fun ChannelState.isLegacy(): Boolean { + return this is ChannelStateWithCommitments + && this !is ShuttingDown && this !is Negotiating && this !is Closing && this !is Closed + // TODO: && !this.commitments.params.channelFeatures.hasFeature(Feature.DualFunding) + } + + /** + * There may be certain "costs" associated with closing a channel: if the channel's balance is below the dust limit, + * then the funds go to the miners. + * + * Channels with balance under 1 sat are ignored. + */ + fun migrationCosts(channels: List): MilliSatoshi { + return channels.filter { channel -> + when (val state = channel.state) { + is Offline -> state.state.isLegacy() + is Syncing -> state.state.isLegacy() + else -> state.isLegacy() + } + }.mapNotNull { channel -> + channel.localBalance?.takeIf { it >= 1_000.msat && it < 546_000.msat } + }.sum() + } + + suspend fun doMigrateChannels( + biz: PhoenixBusiness, + ): IosMigrationResult { + + val loggerFactory = biz.loggerFactory + val peerManager = biz.peerManager + val chain = biz.chain + + try { + val log = loggerFactory.newLogger(this::class) + + val peer = peerManager.getPeer() + val swapInAddress = peer.phoenixSwapInWallet.swapInAddressFlow.filterNotNull().first().first + val closingScript = Parser.addressToPublicKeyScriptOrNull(chain, swapInAddress) + if (closingScript == null) { + log.warning { "aborting: could not get a valid closing script" } + return IosMigrationResult.Failure.InvalidClosingScript + } + + // checking channels + val channelsToMigrate = peerManager.channelsFlow.filterNotNull().first().values.toList().filter { it.state.isLegacy() } + if (channelsToMigrate.isEmpty()) { + log.info { "aborting: no channels to migrate" } + return IosMigrationResult.Failure.NoChannelsAvailable + } else if (channelsToMigrate.any { it.state.isBeingCreated() }) { + log.info { "aborting: some channels are being created" } + return IosMigrationResult.Failure.ChannelsBeingCreated + } + + // Tell the swap-in wallet to "pause". + // That is: don't try to use any of the UTXOs until we're done with our migration. + peer.stopWatchSwapInWallet() + + log.info { "migrating ${channelsToMigrate.size} channels to $swapInAddress" } + // Close all channels in parallel + val mempoolFeerate = biz.phoenixGlobal.feerateManager.mempoolFeerate.filterNotNull().first() + val command = ChannelCommand.Close.MutualClose( + replyTo = CompletableDeferred(), + scriptPubKey = closingScript, + feerate = FeeratePerKw(mempoolFeerate.halfHour) + ) + channelsToMigrate.forEach { + peer.send(WrappedChannelCommand(ByteVector32.fromValidHex(it.channelId), command)) + } + // Wait for the closing tx publication for each consolidated channel (map of channelId -> closing tx id) + val closingTxs: MutableMap = mutableMapOf() + channelsToMigrate.map { ByteVector32.fromValidHex(it.channelId) }.forEach { channelId -> + // Wait until closing tx is published + val channel = peer.channelsFlow + .map { it[channelId] } + .filterNotNull() + .filterIsInstance() + .first { it.mutualClosePublished.isNotEmpty() } + val closingTx = channel.mutualClosePublished.first() + log.info { "mutual-close txid=${closingTx.tx.txid} published for channel=$channelId" } + if (closingTx.toLocalOutput != null) { + closingTxs[channelId] = closingTx.tx.txid + } else { + log.info { "txid=${closingTx.tx.txid} ignored (dust)" } + } + } + log.info { "${closingTxs.size} channels closed to ${closingScript.toHex()}" } + + // Wait for all UTXOs to arrive in swap-in wallet. + peer.phoenixSwapInWallet.wallet.walletStateFlow + .map { it.utxos.map { it.outPoint.txid } } + .first { txidsInWallet -> closingTxs.values.all { txid -> txidsInWallet.contains(txid) } } + log.info { "all mutual-close txids found in swap-in wallet" } + // Resume swap-in + peer.startWatchSwapInWallet() + + return IosMigrationResult.Success(closingTxs) + } catch (e: Exception) { + return IosMigrationResult.Failure.Generic(e) + } + } +} + +sealed class IosMigrationResult { + data class Success(val closingTxs: Map) : IosMigrationResult() + sealed class Failure : IosMigrationResult() { + data class Generic(val error: Throwable) : Failure() + object InvalidClosingScript : Failure() + object NoChannelsAvailable : Failure() + object ChannelsBeingCreated : Failure() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/migrations/LegacyChannelCloseHelper.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/migrations/LegacyChannelCloseHelper.kt new file mode 100644 index 00000000..d084b822 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/migrations/LegacyChannelCloseHelper.kt @@ -0,0 +1,94 @@ +/* + * 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.utils.migrations + +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.ChannelCloseOutgoingPayment +import fr.acinq.lightning.db.ChannelCloseOutgoingPayment.ChannelClosingType +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.sat +import fr.acinq.phoenix.db.migrations.v11.types.OutgoingDetailsData +import fr.acinq.phoenix.db.migrations.v11.types.OutgoingPartClosingInfoData +import fr.acinq.phoenix.db.migrations.v11.types.OutgoingPartClosingInfoTypeVersion +import fr.acinq.phoenix.db.migrations.v11.types.OutgoingStatusData + +object LegacyChannelCloseHelper { + + /** + * Create a [ChannelCloseOutgoingPayment] object from a bunch of legacy data that were stored in closing-tx parts + * and old outgoing status/details. + */ + fun convertLegacyToChannelClose( + id: UUID, + recipientAmount: MilliSatoshi, + detailsBlob: ByteArray?, + statusBlob: ByteArray?, + partsAmount: Satoshi?, + partsTxId: ByteVector32?, + partsClosingTypeBlob: ByteArray?, + createdAt: Long, + confirmedAt: Long?, + ): ChannelCloseOutgoingPayment { + val closingDetails = try { + detailsBlob?.let { OutgoingDetailsData.deserializeLegacyClosingDetails(it) } + } catch (e: Exception) { + null + } + val statusInfoV0 = try { + statusBlob?.let { OutgoingStatusData.deserializeLegacyClosingStatus(it) } + } catch (e: Exception) { + null + } + + val fees = (partsAmount ?: statusInfoV0?.claimed)?.let { recipientAmount.truncateToSatoshi() - it } + ?.takeIf { it > 0.sat } ?: 0.sat + + val closingTxId = partsTxId ?: statusInfoV0?.txIds?.firstOrNull() + val closingType = partsClosingTypeBlob?.let { + OutgoingPartClosingInfoData.deserialize( + typeVersion = OutgoingPartClosingInfoTypeVersion.CLOSING_INFO_V0, + blob = it + ) + } ?: statusInfoV0?.closingType?.let { + try { + ChannelClosingType.valueOf(it) + } catch (e: Exception) { + null + } + } + return ChannelCloseOutgoingPayment( + id = id, + recipientAmount = recipientAmount.truncateToSatoshi() - fees, + address = closingDetails?.closingAddress ?: "", + isSentToDefaultAddress = closingDetails?.isSentToDefaultAddress + ?: (closingType == ChannelClosingType.Local + || closingType == ChannelClosingType.Revoked + || closingType == ChannelClosingType.Remote + || closingType == ChannelClosingType.Other), + miningFee = fees, + txId = TxId(closingTxId ?: ByteVector32.Zeroes), + createdAt = createdAt, + confirmedAt = confirmedAt ?: createdAt, + lockedAt = confirmedAt ?: createdAt, + channelId = closingDetails?.channelId ?: ByteVector32.Zeroes, + closingType = closingType ?: ChannelClosingType.Mutual, + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/GlobalPrefs.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/GlobalPrefs.kt new file mode 100644 index 00000000..9823ea8d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/GlobalPrefs.kt @@ -0,0 +1,133 @@ +/* + * 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.preferences + +import androidx.compose.runtime.Composable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import fr.acinq.lightning.utils.currentTimestampMillis +import fr.acinq.phoenix.data.BaseWalletId +import fr.acinq.phoenix.data.EmptyWalletId +import fr.acinq.phoenix.data.WalletId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.io.IOException +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +class GlobalPrefs(private val data: DataStore) { + + private val jsonFormat = Json { ignoreUnknownKeys = true } + + /** Retrieve preferences from [data], with a fallback to empty prefs if the data file can't be read. */ + private val safeData: Flow = data.data.catch { exception -> + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } + + suspend fun clear() = data.edit { it.clear() } + + private companion object { + // tracks which wallet the app should try to load first, may be null + private val DEFAULT_WALLET_ID = stringPreferencesKey("DEFAULT_WALLET_ID") + private val AVAILABLE_WALLETS_META = stringPreferencesKey("AVAILABLE_WALLETS_META") + + // the FCM token is global for the application and is shared across node_ids + private val FCM_TOKEN = stringPreferencesKey("FCM_TOKEN") + + private val SHOW_INTRO = booleanPreferencesKey("SHOW_INTRO") + private val LAST_USED_APP_CODE = stringPreferencesKey("LAST_USED_APP_CODE") + private val SHOW_RELEASE_NOTES_SINCE = stringPreferencesKey("SHOW_RELEASE_NOTES_SINCE") + } + + /** + * Lists the metadata of known available wallets. Note that this is just metadata, not the actual wallets data like the seed. + * + * Specifically, the Phoenix SeedManager may be managing more or less seeds than this list contain ; if so the SeedManager is + * always right, that is, we should ignore the data returned by that list if they don't match the SeedManager. This should only + * happen if there's a syncing problem between the preferences and the seed file containing the map of seeds. + */ + val getAvailableWalletsMeta: Flow> = safeData.map { + it[AVAILABLE_WALLETS_META]?.let { json -> + try { + jsonFormat.decodeFromString>(json).map { WalletId(it.key) to it.value }.toMap() + } catch (e: Exception) { +// log.error("could not deserialize available_wallets=$json: ", e) + null + } + } ?: emptyMap() + } + + // use this when you know there's not metadata for this wallet id yet in the preference + suspend fun saveAvailableWalletMeta(metadata: UserWalletMetadata) = data.edit { + val existingMap: Map = getAvailableWalletsMeta.first() + val newMap = existingMap + (metadata.walletId to metadata) + it[AVAILABLE_WALLETS_META] = jsonFormat.encodeToString(newMap.map { it.key.nodeIdHash to it.value }.toMap()) + } + suspend fun saveAvailableWalletMeta(walletId: WalletId, name: String?, avatar: String, isHidden: Boolean) = data.edit { + val existingMap: Map = getAvailableWalletsMeta.first() + val newMap = existingMap + (walletId to UserWalletMetadata( + walletId = walletId, + name = name, + avatar = avatar, + createdAt = existingMap[walletId]?.createdAt ?: currentTimestampMillis(), + isHidden = isHidden, + )) + it[AVAILABLE_WALLETS_META] = jsonFormat.encodeToString(newMap.map { it.key.nodeIdHash to it.value }.toMap()) + } + + val getDefaultWallet: Flow = safeData.map { it[DEFAULT_WALLET_ID]?.let { WalletId(it) } ?: EmptyWalletId } + suspend fun saveDefaultWallet(walletId: WalletId) = data.edit { it[DEFAULT_WALLET_ID] = walletId.nodeIdHash } + suspend fun clearDefaultWallet() = data.edit { it.remove(DEFAULT_WALLET_ID) } + + /** Returns the Firebase Cloud Messaging token. */ + val getFcmToken: Flow = safeData.map { it[FCM_TOKEN] } + suspend fun saveFcmToken(token: String) = data.edit { it[FCM_TOKEN] = token } + + /** True if the intro screen must be shown. True by default. */ + val getShowIntro: Flow = safeData.map { it[SHOW_INTRO] ?: true } + suspend fun saveShowIntro(showIntro: Boolean) = data.edit { it[SHOW_INTRO] = showIntro } + + /** Returns the build code of the last Phoenix instance that has been run on the device. Used for migration purposes. */ + val getLastUsedAppCode: Flow = safeData.map { it[LAST_USED_APP_CODE] } + suspend fun saveLastUsedAppCode(code: String) = data.edit { it[LAST_USED_APP_CODE] = code } + + /** For some versions, we want to show a release note when opening the Home screen. This preference tracks from which code notes should be shown. If null, show nothing. */ + val showReleaseNoteSinceCode: Flow = safeData.map { it[SHOW_RELEASE_NOTES_SINCE] } + suspend fun saveShowReleaseNoteSinceCode(code: String?) = data.edit { + if (code == null) it.remove(SHOW_RELEASE_NOTES_SINCE) else it[SHOW_RELEASE_NOTES_SINCE] = code + } +} + +@Serializable +data class UserWalletMetadata(val walletId: WalletId, val name: String?, val avatar: String, val createdAt: Long?, val isHidden: Boolean) { + @Composable + fun nameOrDefault() = name?.takeIf { it.isNotBlank() } ?: "Default name" +} + +/** Helper method that finds the wallet metadata matching the node id in the map, or returns a default value if absent. */ +fun Map.getByWalletIdOrDefault(walletId: WalletId): UserWalletMetadata = this[walletId] ?: UserWalletMetadata(walletId = walletId, name = null, avatar = "", createdAt = null, isHidden = false) diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/InternalPrefs.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/InternalPrefs.kt new file mode 100644 index 00000000..ec1515d9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/InternalPrefs.kt @@ -0,0 +1,115 @@ +/* + * 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.utils.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import fr.acinq.lightning.LiquidityEvents +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.utils.currentTimestampMillis +import fr.acinq.lightning.utils.msat +import fr.acinq.phoenix.data.ChannelsWatcherOutcome +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.io.IOException +import kotlinx.serialization.json.Json + +class InternalPrefs(private val internalData: DataStore) { + private companion object { + private val json = Json { ignoreUnknownKeys = true } + + private val LAST_REJECTED_ONCHAIN_SWAP_AMOUNT = longPreferencesKey("LAST_REJECTED_ONCHAIN_SWAP_AMOUNT") + private val LAST_REJECTED_ONCHAIN_SWAP_TIMESTAMP = longPreferencesKey("LAST_REJECTED_ONCHAIN_SWAP_TIMESTAMP") + private val SEED_MANUAL_BACKUP_DONE = booleanPreferencesKey("SEED_MANUAL_BACKUP_DONE") + private val SEED_LOSS_DISCLAIMER_READ = booleanPreferencesKey("SEED_LOSS_DISCLAIMER_READ") + internal val CHANNELS_WATCHER_OUTCOME = stringPreferencesKey("CHANNELS_WATCHER_RESULT") + private val LAST_USED_SWAP_INDEX = intPreferencesKey("LAST_USED_SWAP_INDEX") + private val INFLIGHT_PAYMENTS_COUNT = intPreferencesKey("INFLIGHT_PAYMENTS_COUNT") + private val SHOW_SPLICEOUT_CAPACITY_DISCLAIMER = booleanPreferencesKey("SHOW_SPLICEOUT_CAPACITY_DISCLAIMER") + private val REMOTE_WALLET_NOTICE_READ_INDEX = intPreferencesKey("REMOTE_WALLET_NOTICE_READ_INDEX") + private val BIP_353_ADDRESS = stringPreferencesKey("BIP_353_ADDRESS") + } + + /** Retrieve data stored in [internalData], with a fallback to empty data if prefs file can't be read. */ + private val safeData: Flow = internalData.data.catch { exception -> + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } + + suspend fun clear() = internalData.edit { it.clear() } + + /** True when the user states that he made a manual backup of the seed. */ + val isManualSeedBackupDone: Flow = safeData.map { it[SEED_MANUAL_BACKUP_DONE] ?: false } + suspend fun saveManualSeedBackupDone(isDone: Boolean) = internalData.edit { it[SEED_MANUAL_BACKUP_DONE] = isDone } + + /** True if the user has read the seed loss disclaimer. */ + val isSeedLossDisclaimerRead: Flow = safeData.map { it[SEED_LOSS_DISCLAIMER_READ] ?: false } + suspend fun saveSeedLossDisclaimerRead(isRead: Boolean) = internalData.edit { it[SEED_LOSS_DISCLAIMER_READ] = isRead } + + /** True if a seed backup warning should be displayed - computed from SEED_MANUAL_BACKUP_DONE & SEED_LOSS_DISCLAIMER_READ. */ + val showSeedBackupNotice = safeData.map { it[SEED_MANUAL_BACKUP_DONE] != true || it[SEED_LOSS_DISCLAIMER_READ] != true } + + /** Returns the last hannels-watcher job result. */ + val getChannelsWatcherOutcome: Flow = safeData.map { + it[CHANNELS_WATCHER_OUTCOME]?.let { + try { + json.decodeFromString(it) + } catch (e: Exception) { + null + } + } + } + suspend fun saveChannelsWatcherOutcome(channelsWatcherOutcome: ChannelsWatcherOutcome) = internalData.edit { + it[CHANNELS_WATCHER_OUTCOME] = json.encodeToString(channelsWatcherOutcome) + } + + /** Return (amount, timestamp) of the last rejected swap-in. Prevent spamming user with duplicate notifications for the same on-chain deposit. */ + val getLastRejectedOnchainSwap: Flow?> = safeData.map { + val amount = it[LAST_REJECTED_ONCHAIN_SWAP_AMOUNT] + val timestamp = it[LAST_REJECTED_ONCHAIN_SWAP_TIMESTAMP] + if (amount != null && timestamp != null) amount.msat to timestamp else null + } + suspend fun saveLastRejectedOnchainSwap(liquidityEvent: LiquidityEvents.Rejected) = internalData.edit { + it[LAST_REJECTED_ONCHAIN_SWAP_AMOUNT] = liquidityEvent.amount.msat + it[LAST_REJECTED_ONCHAIN_SWAP_TIMESTAMP] = currentTimestampMillis() + } + + val getLastUsedSwapIndex: Flow = safeData.map { it[LAST_USED_SWAP_INDEX] ?: 0 } + suspend fun saveLastUsedSwapIndex(index: Int) = internalData.edit { it[LAST_USED_SWAP_INDEX] = index } + + val getInFlightPaymentsCount: Flow = safeData.map { it[INFLIGHT_PAYMENTS_COUNT] ?: 0 } + suspend fun saveInFlightPaymentsCount(count: Int) = internalData.edit { it[INFLIGHT_PAYMENTS_COUNT] = count } + + val getSpliceoutCapacityDisclaimer: Flow = safeData.map { it[SHOW_SPLICEOUT_CAPACITY_DISCLAIMER] ?: true } + suspend fun saveSpliceoutCapacityDisclaimer(show: Boolean) = internalData.edit { it[SHOW_SPLICEOUT_CAPACITY_DISCLAIMER] = show } + + val getLastReadWalletNoticeIndex: Flow = safeData.map { it[REMOTE_WALLET_NOTICE_READ_INDEX] ?: -1 } + suspend fun saveLastReadWalletNoticeIndex(index: Int) = internalData.edit { it[REMOTE_WALLET_NOTICE_READ_INDEX] = index } + + val getBip353Address: Flow = safeData.map { it[BIP_353_ADDRESS] } + suspend fun saveBip353Address(address: String) = internalData.edit { it[BIP_353_ADDRESS] = address } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/UserPrefs.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/UserPrefs.kt new file mode 100644 index 00000000..1b8995ca --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/UserPrefs.kt @@ -0,0 +1,383 @@ +/* + * 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.utils.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.* +import co.touchlab.kermit.Logger +import fr.acinq.bitcoin.Satoshi +import fr.acinq.lightning.CltvExpiryDelta +import fr.acinq.lightning.TrampolineFees +import fr.acinq.lightning.io.TcpSocket +import fr.acinq.lightning.payment.LiquidityPolicy +import fr.acinq.lightning.utils.ServerAddress +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sat +import fr.acinq.phoenix.data.BitcoinUnit +import fr.acinq.phoenix.data.ElectrumConfig +import fr.acinq.phoenix.data.FiatCurrency +import fr.acinq.phoenix.data.PreferredFiatCurrencies +import fr.acinq.phoenix.data.UserTheme +import fr.acinq.phoenix.data.lnurl.LnurlAuth +import fr.acinq.phoenix.db.migrations.v10.json.SatoshiSerializer +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.utils.DateUtils +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.io.IOException +import kotlinx.serialization.* +import kotlinx.serialization.json.Json +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +class UserPrefs(private val data: DataStore) { + + private val log = Logger.withTag("UserPrefs") + private val jsonFormat = Json { ignoreUnknownKeys = true } // some prefs are json-serialized + + /** Retrieve preferences from [data], with a fallback to empty prefs if the data file can't be read. */ + private val safeData: Flow = data.data.catch { exception -> + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } + suspend fun clear() = data.edit { it.clear() } + + private companion object { + // display + private val BITCOIN_UNIT = stringPreferencesKey("BITCOIN_UNIT") + private val BITCOIN_UNITS = stringPreferencesKey("BITCOIN_UNITS") + private val FIAT_CURRENCY = stringPreferencesKey("FIAT_CURRENCY") + private val FIAT_CURRENCIES = stringPreferencesKey("FIAT_CURRENCIES") + private val SHOW_AMOUNT_IN_FIAT = booleanPreferencesKey("SHOW_AMOUNT_IN_FIAT") + private val HOME_AMOUNT_DISPLAY_MODE = stringPreferencesKey("HOME_AMOUNT_DISPLAY_MODE") + private val THEME = stringPreferencesKey("THEME") + // electrum + val PREFS_ELECTRUM_ADDRESS_HOST = stringPreferencesKey("PREFS_ELECTRUM_ADDRESS_HOST") + val PREFS_ELECTRUM_ADDRESS_PORT = intPreferencesKey("PREFS_ELECTRUM_ADDRESS_PORT") + val PREFS_ELECTRUM_ADDRESS_REQUIRE_ONION_IF_TOR_ENABLED = booleanPreferencesKey("PREFS_ELECTRUM_ADDRESS_REQUIRE_ONION_IF_TOR_ENABLED") + val PREFS_ELECTRUM_ADDRESS_PINNED_KEY = stringPreferencesKey("PREFS_ELECTRUM_ADDRESS_PINNED_KEY") + // access control + val PREFS_LOCK_BIOMETRICS_ENABLED = booleanPreferencesKey("PREFS_SCREEN_LOCK") + val PREFS_LOCK_PIN_ENABLED = booleanPreferencesKey("PREFS_SCREEN_LOCK_CUSTOM_PIN_ENABLED") + val PREFS_LOCK_PIN_ATTEMPT_COUNT = intPreferencesKey("PREFS_CUSTOM_PIN_ATTEMPT_COUNT") + val PREFS_AUTO_LOCK_DELAY = longPreferencesKey("PREFS_AUTO_LOCK_DELAY") + val PREFS_SPENDING_PIN_ENABLED = booleanPreferencesKey("PREFS_SPEND_LOCK_CUSTOM_PIN_ENABLED") + val PREFS_SPENDING_PIN_ATTEMPT_COUNT = intPreferencesKey("PREFS_SPEND_LOCK_CUSTOM_PIN_ATTEMPT_COUNT") + val PREFS_SHUFFLE_PIN_KEYBOARD = booleanPreferencesKey("PREFS_SHUFFLE_PIN_KEYBOARD") + // payments options + private val INVOICE_DEFAULT_DESC = stringPreferencesKey("INVOICE_DEFAULT_DESC") + private val INVOICE_DEFAULT_EXPIRY = longPreferencesKey("INVOICE_DEFAULT_EXPIRY") + private val TRAMPOLINE_MAX_BASE_FEE = longPreferencesKey("TRAMPOLINE_MAX_BASE_FEE") + private val TRAMPOLINE_MAX_PROPORTIONAL_FEE = longPreferencesKey("TRAMPOLINE_MAX_PROPORTIONAL_FEE") + private val SWAP_ADDRESS_FORMAT = intPreferencesKey("SWAP_ADDRESS_FORMAT") + private val LNURL_AUTH_SCHEME = intPreferencesKey("LNURL_AUTH_SCHEME") + private val IS_OVERPAYMENT_ENABLED = booleanPreferencesKey("IS_OVERPAYMENT_ENABLED") + // liquidity policy & channels management + private val LIQUIDITY_POLICY = stringPreferencesKey("LIQUIDITY_POLICY") + private val INCOMING_MAX_SAT_FEE_INTERNAL_TRACKER = longPreferencesKey("INCOMING_MAX_SAT_FEE_INTERNAL_TRACKER") + private val INCOMING_MAX_PROP_FEE_INTERNAL_TRACKER = intPreferencesKey("INCOMING_MAX_PROP_FEE_INTERNAL_TRACKER") + // tor + private val IS_TOR_ENABLED = booleanPreferencesKey("IS_TOR_ENABLED") + // misc + private val SHOW_NOTIFICATION_PERMISSION_REMINDER = booleanPreferencesKey("SHOW_NOTIFICATION_PERMISSION_REMINDER") + } + + val getBitcoinUnits: Flow = safeData.map { + it[BITCOIN_UNITS]?.let { + try { + jsonFormat.decodeFromString(it) + } catch (e: Exception) { + log.e("failed to decode bitcoin units: $it: ${e.message}") + null + } + } ?: PreferredBitcoinUnits(primary = it[BITCOIN_UNIT]?.let { BitcoinUnit.valueOfOrNull(it) } ?: BitcoinUnit.Sat) + } + suspend fun saveBitcoinUnits(units: PreferredBitcoinUnits) = data.edit { + try { + it[BITCOIN_UNITS] = Json.encodeToString(units) + } catch (e: Exception) { + log.e("failed to save bitcoin units list: $units: ${e.message}") + } + } + + val getFiatCurrencies: Flow = safeData.map { + it[FIAT_CURRENCIES]?.let { + try { + jsonFormat.decodeFromString(it) + } catch (e: Exception) { + log.e("failed to decode fiat currencies list: $it: ${e.message}") + null + } + } ?: PreferredFiatCurrencies( + // fallback to the legacy property + primary = it[FIAT_CURRENCY]?.let { FiatCurrency.valueOfOrNull(it) } ?: FiatCurrency.USD, + others = emptySet() + ) + } + suspend fun saveFiatCurrencyList(preferredCurrencies: PreferredFiatCurrencies) = data.edit { + try { + it[FIAT_CURRENCIES] = Json.encodeToString(preferredCurrencies) + } catch (e: Exception) { + log.e("failed to save fiat currencies list: ${e.message}") + } + } + + val getIsAmountInFiat: Flow = safeData.map { it[SHOW_AMOUNT_IN_FIAT] ?: false } + suspend fun saveIsAmountInFiat(inFiat: Boolean) = data.edit { it[SHOW_AMOUNT_IN_FIAT] = inFiat } + + val getHomeAmountDisplayMode: Flow = safeData.map { + HomeAmountDisplayMode.safeValueOf(it[HOME_AMOUNT_DISPLAY_MODE]) + } + suspend fun saveHomeAmountDisplayMode(displayMode: HomeAmountDisplayMode) = data.edit { + it[HOME_AMOUNT_DISPLAY_MODE] = displayMode.name + when (displayMode) { + HomeAmountDisplayMode.FIAT -> it[SHOW_AMOUNT_IN_FIAT] = true + HomeAmountDisplayMode.BTC -> it[SHOW_AMOUNT_IN_FIAT] = false + else -> Unit + } + } + + val getUserTheme: Flow = safeData.map { UserTheme.safeValueOf(it[THEME]) } + suspend fun saveUserTheme(theme: UserTheme) = data.edit { it[THEME] = theme.name } + + val getElectrumServer: Flow = safeData.map { + val host = it[PREFS_ELECTRUM_ADDRESS_HOST]?.takeIf { it.isNotBlank() } + val port = it[PREFS_ELECTRUM_ADDRESS_PORT] + val requireOnionIfTorEnabled = it[PREFS_ELECTRUM_ADDRESS_REQUIRE_ONION_IF_TOR_ENABLED] ?: true + val pinnedKey = it[PREFS_ELECTRUM_ADDRESS_PINNED_KEY]?.takeIf { it.isNotBlank() } + log.d("retrieved electrum address from datastore, host=$host port=$port key=$pinnedKey") + if (host != null && port != null && pinnedKey == null) { + ElectrumConfig.Custom.create(ServerAddress(host, port, TcpSocket.TLS.TRUSTED_CERTIFICATES()), requireOnionIfTorEnabled) + } else if (host != null && port != null && pinnedKey != null) { + ElectrumConfig.Custom.create(ServerAddress(host, port, TcpSocket.TLS.PINNED_PUBLIC_KEY(pinnedKey)), requireOnionIfTorEnabled) + } else { + null + } + } + + suspend fun saveElectrumServer(config: ElectrumConfig.Custom?) = data.edit { + if (config == null) { + it.remove(PREFS_ELECTRUM_ADDRESS_HOST) + it.remove(PREFS_ELECTRUM_ADDRESS_PORT) + it.remove(PREFS_ELECTRUM_ADDRESS_PINNED_KEY) + it.remove(PREFS_ELECTRUM_ADDRESS_REQUIRE_ONION_IF_TOR_ENABLED) + } else { + it[PREFS_ELECTRUM_ADDRESS_HOST] = config.server.host + it[PREFS_ELECTRUM_ADDRESS_PORT] = config.server.port + it[PREFS_ELECTRUM_ADDRESS_REQUIRE_ONION_IF_TOR_ENABLED] = config.requireOnionIfTorEnabled + val tls = config.server.tls + if (tls is TcpSocket.TLS.PINNED_PUBLIC_KEY) { + it[PREFS_ELECTRUM_ADDRESS_PINNED_KEY] = tls.pubKey + } else { + it.remove(PREFS_ELECTRUM_ADDRESS_PINNED_KEY) + } + } + } + + // -- security + + val getLockBiometricsEnabled: Flow = safeData.map { it[PREFS_LOCK_BIOMETRICS_ENABLED] ?: false } + suspend fun saveIsScreenLockBiometricsEnabled(isEnabled: Boolean) = data.edit { it[PREFS_LOCK_BIOMETRICS_ENABLED] = isEnabled } + + val getLockPinEnabled: Flow = safeData.map { it[PREFS_LOCK_PIN_ENABLED] ?: false } + suspend fun saveIsScreenLockPinEnabled(isEnabled: Boolean) = data.edit { it[PREFS_LOCK_PIN_ENABLED] = isEnabled } + + val getLockPinCodeAttempt: Flow = safeData.map { + it[PREFS_LOCK_PIN_ATTEMPT_COUNT] ?: 0 + } + suspend fun saveLockPinCodeFailure() = data.edit { + it[PREFS_LOCK_PIN_ATTEMPT_COUNT] = (it[PREFS_LOCK_PIN_ATTEMPT_COUNT] ?: 0) + 1 + } + suspend fun saveLockPinCodeSuccess() = data.edit { + it[PREFS_LOCK_PIN_ATTEMPT_COUNT] = 0 + } + + val getAutoLockDelay: Flow = safeData.map { + it[PREFS_AUTO_LOCK_DELAY]?.toDuration(DurationUnit.MILLISECONDS) ?: 10.minutes + } + suspend fun saveAutoLockDelay(delay: Duration) = data.edit { + it[PREFS_AUTO_LOCK_DELAY] = delay.inWholeMilliseconds + } + + val getSpendingPinEnabled: Flow = safeData.map { it[PREFS_SPENDING_PIN_ENABLED] ?: false } + suspend fun saveIsSpendLockPinEnabled(isEnabled: Boolean) = data.edit { it[PREFS_SPENDING_PIN_ENABLED] = isEnabled } + + val getSpendingPinCodeAttempt: Flow = safeData.map { + it[PREFS_SPENDING_PIN_ATTEMPT_COUNT] ?: 0 + } + suspend fun saveSpendingPinCodeFailure() = data.edit { + it[PREFS_SPENDING_PIN_ATTEMPT_COUNT] = (it[PREFS_SPENDING_PIN_ATTEMPT_COUNT] ?: 0) + 1 + } + suspend fun saveSpendingPinCodeSuccess() = data.edit { + it[PREFS_SPENDING_PIN_ATTEMPT_COUNT] = 0 + } + + val getIsPinKeyboardShuffled: Flow = safeData.map { it[PREFS_SHUFFLE_PIN_KEYBOARD] ?: false } + suspend fun saveIsPinKeyboardShuffled(isShuffled: Boolean) = data.edit { it[PREFS_SHUFFLE_PIN_KEYBOARD] = isShuffled } + + val getInvoiceDefaultDesc: Flow = safeData.map { it[INVOICE_DEFAULT_DESC]?.takeIf { it.isNotBlank() } ?: "" } + suspend fun saveInvoiceDefaultDesc(description: String) = data.edit { it[INVOICE_DEFAULT_DESC] = description } + + val getInvoiceDefaultExpiry: Flow = safeData.map { it[INVOICE_DEFAULT_EXPIRY] ?: (DateUtils.WEEK_IN_MILLIS / 1000) } + suspend fun saveInvoiceDefaultExpiry(expirySeconds: Long) = data.edit { it[INVOICE_DEFAULT_EXPIRY] = expirySeconds } + + val getTrampolineMaxFee: Flow = safeData.map { + val feeBase = it[TRAMPOLINE_MAX_BASE_FEE]?.sat + val feeProportional = it[TRAMPOLINE_MAX_PROPORTIONAL_FEE] + if (feeBase != null && feeProportional != null) { + TrampolineFees(feeBase, feeProportional, CltvExpiryDelta(144)) + } else null + } + + suspend fun saveTrampolineMaxFee(fee: TrampolineFees?) = data.edit { + if (fee == null) { + it.remove(TRAMPOLINE_MAX_BASE_FEE) + it.remove(TRAMPOLINE_MAX_PROPORTIONAL_FEE) + } else { + it[TRAMPOLINE_MAX_BASE_FEE] = fee.feeBase.toLong() + it[TRAMPOLINE_MAX_PROPORTIONAL_FEE] = fee.feeProportional + } + } + + val getSwapAddressFormat: Flow = safeData.map { + it[SWAP_ADDRESS_FORMAT]?.let { SwapAddressFormat.getFormatForCode(it) } ?: SwapAddressFormat.TAPROOT_ROTATE + } + suspend fun saveSwapAddressFormat(format: SwapAddressFormat) = data.edit { + log.i("saving swap-address-format=$format") + it[SWAP_ADDRESS_FORMAT] = format.code + } + + val getLiquidityPolicy: Flow = safeData.map { + try { + it[LIQUIDITY_POLICY]?.let { policy -> + when (val res = jsonFormat.decodeFromString(policy)) { + is InternalLiquidityPolicy.Auto -> LiquidityPolicy.Auto( + inboundLiquidityTarget = null, + maxAbsoluteFee = res.maxAbsoluteFee, + maxRelativeFeeBasisPoints = res.maxRelativeFeeBasisPoints, + skipAbsoluteFeeCheck = res.skipAbsoluteFeeCheck, + maxAllowedFeeCredit = 0.msat, + ) + is InternalLiquidityPolicy.Disable -> LiquidityPolicy.Disable + } + } + } catch (e: Exception) { + log.e("failed to read liquidity-policy preference, replace with default: ${e.message}") + saveLiquidityPolicy(NodeParamsManager.defaultLiquidityPolicy) + null + } ?: NodeParamsManager.defaultLiquidityPolicy + } + + suspend fun saveLiquidityPolicy(policy: LiquidityPolicy) = data.edit { + log.i("saving new liquidity policy=$policy") + val serialisable = when (policy) { + is LiquidityPolicy.Auto -> InternalLiquidityPolicy.Auto(policy.maxRelativeFeeBasisPoints, policy.maxAbsoluteFee, policy.skipAbsoluteFeeCheck) + is LiquidityPolicy.Disable -> InternalLiquidityPolicy.Disable + } + it[LIQUIDITY_POLICY] = jsonFormat.encodeToString(serialisable) + // also save the fee so that we don't lose the user fee preferences even when using a disabled policy + if (policy is LiquidityPolicy.Auto) { + it[INCOMING_MAX_SAT_FEE_INTERNAL_TRACKER] = policy.maxAbsoluteFee.sat + it[INCOMING_MAX_PROP_FEE_INTERNAL_TRACKER] = policy.maxRelativeFeeBasisPoints + } + } + + /** This is used to keep track of the user's max fee preferences, even if he's not currently using a relevant liquidity policy. */ + val getIncomingMaxSatFeeInternal: Flow = safeData.map { + it[INCOMING_MAX_SAT_FEE_INTERNAL_TRACKER]?.sat ?: NodeParamsManager.defaultLiquidityPolicy.maxAbsoluteFee + } + + /** This is used to keep track of the user's proportional fee preferences, even if he's not currently using a relevant liquidity policy. */ + val getIncomingMaxPropFeeInternal: Flow = safeData.map { + it[INCOMING_MAX_PROP_FEE_INTERNAL_TRACKER] ?: NodeParamsManager.defaultLiquidityPolicy.maxRelativeFeeBasisPoints + } + + val getLnurlAuthScheme: Flow = safeData.map { + when (it[LNURL_AUTH_SCHEME]) { + LnurlAuth.Scheme.DEFAULT_SCHEME.id -> LnurlAuth.Scheme.DEFAULT_SCHEME + LnurlAuth.Scheme.ANDROID_LEGACY_SCHEME.id -> LnurlAuth.Scheme.ANDROID_LEGACY_SCHEME + else -> LnurlAuth.Scheme.DEFAULT_SCHEME + } + } + + suspend fun saveLnurlAuthScheme(scheme: LnurlAuth.Scheme?) = data.edit { + if (scheme == null) { + it.remove(LNURL_AUTH_SCHEME) + } else { + it[LNURL_AUTH_SCHEME] = scheme.id + } + } + + val getIsOverpaymentEnabled: Flow = safeData.map { it[IS_OVERPAYMENT_ENABLED] ?: false } + suspend fun saveIsOverpaymentEnabled(enabled: Boolean) = data.edit { it[IS_OVERPAYMENT_ENABLED] = enabled } + + val getIsTorEnabled: Flow = safeData.map { it[IS_TOR_ENABLED] ?: false } + suspend fun saveIsTorEnabled(isEnabled: Boolean) = data.edit { it[IS_TOR_ENABLED] = isEnabled } + + val getShowNotificationPermissionReminder: Flow = safeData.map { it[SHOW_NOTIFICATION_PERMISSION_REMINDER] ?: true } + suspend fun saveShowNotificationPermissionReminder(show: Boolean) = data.edit { it[SHOW_NOTIFICATION_PERMISSION_REMINDER] = show } +} + +/** Our own format for [LiquidityPolicy], serializable and decoupled from lightning-kmp. */ +@Serializable +sealed class InternalLiquidityPolicy { + @Serializable + data object Disable : InternalLiquidityPolicy() + + @Serializable + data class Auto( + val maxRelativeFeeBasisPoints: Int, + @Serializable(with = SatoshiSerializer::class) val maxAbsoluteFee: Satoshi, + val skipAbsoluteFeeCheck: Boolean + ) : InternalLiquidityPolicy() +} + +enum class HomeAmountDisplayMode { + BTC, FIAT, REDACTED; + + companion object { + fun safeValueOf(mode: String?) = when (mode) { + FIAT.name -> FIAT + REDACTED.name -> REDACTED + else -> BTC + } + } +} + +enum class SwapAddressFormat(val code: Int) { + LEGACY(0), TAPROOT_ROTATE(1); + companion object { + fun getFormatForCode(code: Int) = when (code) { + 0 -> LEGACY + else -> TAPROOT_ROTATE + } + } +} + +@Serializable +data class PreferredBitcoinUnits( + val primary: BitcoinUnit, + val others: List = emptyList() +) { + val all by lazy { listOf(primary) + others } +} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/UserPrefsComposeExtensions.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/UserPrefsComposeExtensions.kt new file mode 100644 index 00000000..a7b44d55 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/UserPrefsComposeExtensions.kt @@ -0,0 +1,42 @@ +/* + * 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.preferences + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import fr.acinq.phoenix.data.BitcoinUnit +import fr.acinq.phoenix.data.FiatCurrency +import fr.acinq.phoenix.data.PreferredFiatCurrencies +import fr.acinq.phoenix.managers.AppConfigurationManager +import kotlinx.coroutines.flow.flowOf + +@Composable +fun UserPrefs?.getHomeAmountDisplayMode() : State = + (this?.getHomeAmountDisplayMode ?: flowOf(HomeAmountDisplayMode.REDACTED)).collectAsState(initial = HomeAmountDisplayMode.REDACTED) + +@Composable +fun UserPrefs?.getIsAmountInFiat(): State = + (this?.getIsAmountInFiat ?: flowOf(false)).collectAsState(initial = false) + +@Composable +fun UserPrefs?.getBitcoinUnits(): State = + (this?.getBitcoinUnits ?: flowOf(PreferredBitcoinUnits(primary = BitcoinUnit.Sat))).collectAsState(initial = PreferredBitcoinUnits(primary = BitcoinUnit.Sat)) + +@Composable +fun UserPrefs?.getFiatCurrencies(): State = + (this?.getFiatCurrencies ?: flowOf(PreferredFiatCurrencies(primary = FiatCurrency.USD, others = emptyList()))).collectAsState(initial = PreferredFiatCurrencies(primary = FiatCurrency.USD, others = emptyList())) diff --git a/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/ExchangeRates.sq b/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/ExchangeRates.sq new file mode 100644 index 00000000..e3c1bae1 --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/ExchangeRates.sq @@ -0,0 +1,26 @@ +import fr.acinq.phoenix.data.ExchangeRate; + +CREATE TABLE IF NOT EXISTS exchange_rates ( + fiat TEXT NOT NULL PRIMARY KEY, + price REAL NOT NULL, + type TEXT AS ExchangeRate.Type NOT NULL, + source TEXT NOT NULL, + updated_at INTEGER NOT NULL +); + +insert: +INSERT INTO exchange_rates( + fiat, price, type, source, updated_at +) VALUES (?, ?, ?, ?, ?); + +update: +UPDATE exchange_rates SET price=?, type=?, source=?, updated_at=? WHERE fiat=?; + +get: +SELECT * FROM exchange_rates WHERE fiat=?; + +list: +SELECT * FROM exchange_rates; + +delete: +DELETE FROM exchange_rates WHERE fiat=?; diff --git a/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/KeyValueStore.sq b/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/KeyValueStore.sq new file mode 100644 index 00000000..588af5cf --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/KeyValueStore.sq @@ -0,0 +1,22 @@ +-- Generic key/value store + +CREATE TABLE IF NOT EXISTS key_value_store ( + key TEXT NOT NULL PRIMARY KEY, + value BLOB NOT NULL, + updated_at INTEGER NOT NULL +); + +get: +SELECT * FROM key_value_store WHERE key = ?; + +exists: +SELECT COUNT(*) FROM key_value_store WHERE key = ?; + +insert: +INSERT INTO key_value_store(key, value, updated_at) VALUES (?, ?, ?); + +update: +UPDATE key_value_store SET value = ?, updated_at = ? WHERE key = ?; + +delete: +DELETE FROM key_value_store WHERE key = ?; \ No newline at end of file diff --git a/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/Notifications.sq b/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/Notifications.sq new file mode 100644 index 00000000..b9ca7b6a --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/Notifications.sq @@ -0,0 +1,42 @@ +-- This table stores notifications of all kinds. +-- * id => UUID of a notification +-- * type_version => string tracking the type/version of a notification +-- * data_json => json-serialized blob containing the notification details +-- * created_at => when the notification was created, in millis +-- * read_at => when the notification was read, in millis. Read notifications are typically not shown anymore. +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT NOT NULL PRIMARY KEY, + type_version TEXT NOT NULL, + data_json BLOB NOT NULL, + created_at INTEGER NOT NULL, + read_at INTEGER DEFAULT NULL, + node_id_hash TEXT DEFAULT NULL +); + +-- Create index to optimize the `listUnread` query. +-- Tip: Use "explain query plan" to ensure they're actually being used. +CREATE INDEX notifications_source ON notifications(node_id_hash) WHERE read_at IS NULL; + +listUnread: +SELECT id, group_concat(id, ';') AS grouped_ids, type_version, data_json, max(created_at) +FROM notifications +WHERE read_at IS NULL AND node_id_hash = ? +GROUP BY type_version, data_json +ORDER BY created_at DESC; + +get: +SELECT id, type_version, data_json, created_at, read_at FROM notifications WHERE id=?; + +insert: +INSERT INTO notifications ( + id, type_version, data_json, created_at, node_id_hash +) VALUES (?, ?, ?, ?, ?); + +markAsRead: +UPDATE notifications SET read_at=? WHERE id IN ?; + +markAllAsRead: +UPDATE notifications SET read_at=? WHERE read_at IS NULL; + +initializeNodeIdHashColumn: +UPDATE notifications SET node_id_hash=? WHERE node_id_hash IS NULL; diff --git a/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/1.sqm b/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/1.sqm new file mode 100644 index 00000000..87018b1f --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/1.sqm @@ -0,0 +1,10 @@ +-- Migration: v1 -> v2 +-- +-- Changes: +-- * Added table key_value_store + +CREATE TABLE IF NOT EXISTS key_value_store ( + key TEXT NOT NULL PRIMARY KEY, + value BLOB NOT NULL, + updated_at INTEGER NOT NULL +); diff --git a/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/2.sqm b/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/2.sqm new file mode 100644 index 00000000..6ef3418e --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/2.sqm @@ -0,0 +1,15 @@ +-- Migration: v2 -> v3 +-- +-- Changes: +-- * Deleted table bitcoin_price_rates +-- * Added table exchange_rates + +DROP TABLE IF EXISTS bitcoin_price_rates; + +CREATE TABLE IF NOT EXISTS exchange_rates ( + fiat TEXT NOT NULL PRIMARY KEY, + price REAL NOT NULL, + type TEXT NOT NULL, + source TEXT NOT NULL, + updated_at INTEGER NOT NULL +); diff --git a/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/3.sqm b/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/3.sqm new file mode 100644 index 00000000..d65db347 --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/3.sqm @@ -0,0 +1,12 @@ +-- Migration: v3 -> v4 +-- +-- Changes: +-- * add notifications table + +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT NOT NULL PRIMARY KEY, + type_version TEXT AS NotificationTypeVersion NOT NULL, + data_json BLOB NOT NULL, + created_at INTEGER NOT NULL, + read_at INTEGER DEFAULT NULL +); \ No newline at end of file diff --git a/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/4.sqm b/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/4.sqm new file mode 100644 index 00000000..051a5847 --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/4.sqm @@ -0,0 +1,22 @@ +-- Migration: v4 -> v5 +-- +-- Changes: +-- * add contacts table +-- * add contact_offers table + +CREATE TABLE IF NOT EXISTS contacts ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + photo_uri TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER DEFAULT NULL +); + +CREATE TABLE IF NOT EXISTS contact_offers ( + offer_id BLOB NOT NULL PRIMARY KEY, + contact_id TEXT NOT NULL, + offer TEXT NOT NULL, + created_at INTEGER NOT NULL, + + FOREIGN KEY(contact_id) REFERENCES contacts(id) +); diff --git a/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/5.sqm b/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/5.sqm new file mode 100644 index 00000000..8a79aea7 --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/5.sqm @@ -0,0 +1,7 @@ +-- Migration: v5 -> v6 +-- +-- Changes: +-- * add use_offer_key flag to the contacts table. By default, a contact is trusted. + +ALTER TABLE contacts + ADD COLUMN use_offer_key INTEGER AS Boolean DEFAULT 1 NOT NULL; \ No newline at end of file diff --git a/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/6.sqm b/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/6.sqm new file mode 100644 index 00000000..67973b55 --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/6.sqm @@ -0,0 +1,23 @@ +-- Migration: v6 -> v7 +-- +-- Changes: +-- * Added table cloudkit_contacts_metadata +-- * Added index on table cloudkit_contacts_metadata +-- * Added table cloudkit_contacts_queue +-- +-- See CloudKitContacts.sq for more details. + +CREATE TABLE IF NOT EXISTS cloudkit_contacts_metadata ( + id TEXT NOT NULL PRIMARY KEY, + record_creation INTEGER NOT NULL, + record_blob BLOB NOT NULL +); + +CREATE INDEX IF NOT EXISTS record_creation_idx +ON cloudkit_contacts_metadata(record_creation); + +CREATE TABLE IF NOT EXISTS cloudkit_contacts_queue ( + rowid INTEGER PRIMARY KEY, + id TEXT NOT NULL, + date_added INTEGER NOT NULL +); diff --git a/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/7.sqm b/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/7.sqm new file mode 100644 index 00000000..a7e4f029 --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/7.sqm @@ -0,0 +1,26 @@ +-- Migration: v7 -> v8 +-- +-- Changes: +-- * Migrating contacts to paymentsDb +-- + +DROP INDEX IF EXISTS contact_id_index; + +--- The original `contact_offers` table has a FOREIGN KEY constraint. +--- This is going to cause a problem when we rename the contacts table. +--- And there's no way to drop a constraint in sqlite. +--- So we need to migrate the old table to a new one without the constraint. +CREATE TABLE IF NOT EXISTS contact_offers_old ( + offer_id BLOB NOT NULL PRIMARY KEY, + contact_id TEXT NOT NULL, + offer TEXT NOT NULL, + created_at INTEGER NOT NULL +); +INSERT INTO contact_offers_old SELECT * FROM contact_offers; +DROP TABLE IF EXISTS contact_offers; + +ALTER TABLE contacts RENAME TO contacts_old; + +DROP INDEX IF EXISTS record_creation_idx; +ALTER TABLE cloudkit_contacts_metadata RENAME TO cloudkit_contacts_metadata_old; +DROP TABLE IF EXISTS cloudkit_contacts_queue; diff --git a/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/8.sqm b/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/8.sqm new file mode 100644 index 00000000..362fac36 --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/appdb/fr/acinq/phoenix/db/sqldelight/migrations/8.sqm @@ -0,0 +1,10 @@ +-- Migration: v8 -> v9 +-- +-- Changes: +-- * Adding column `node_id_hash` to notifications table +-- + +ALTER TABLE notifications + ADD COLUMN node_id_hash TEXT DEFAULT NULL; + +CREATE INDEX notifications_source ON notifications(node_id_hash) WHERE read_at IS NULL; \ No newline at end of file diff --git a/composeApp/src/commonMain/sqldelight/channelsdb/fr/acinq/phoenix/db/sqldelight/ChannelsDatabase.sq b/composeApp/src/commonMain/sqldelight/channelsdb/fr/acinq/phoenix/db/sqldelight/ChannelsDatabase.sq new file mode 100644 index 00000000..a90637b2 --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/channelsdb/fr/acinq/phoenix/db/sqldelight/ChannelsDatabase.sq @@ -0,0 +1,48 @@ +import fr.acinq.bitcoin.ByteVector32; +import fr.acinq.lightning.channel.states.PersistedChannelState; +import kotlin.Boolean; + +-- channels table +-- note: boolean are stored as INTEGER, with 0=false +CREATE TABLE local_channels ( + channel_id BLOB AS ByteVector32 NOT NULL PRIMARY KEY, + data BLOB AS PersistedChannelState NOT NULL, + is_closed INTEGER AS Boolean DEFAULT 0 NOT NULL +); + +-- htlcs info table +CREATE TABLE htlc_infos ( + channel_id BLOB AS ByteVector32 NOT NULL, + commitment_number INTEGER NOT NULL, + payment_hash BLOB AS ByteVector32 NOT NULL, + cltv_expiry INTEGER NOT NULL, + FOREIGN KEY(channel_id) REFERENCES local_channels(channel_id) +); + +CREATE INDEX htlc_infos_idx ON htlc_infos(channel_id, commitment_number); + +-- channels queries +getChannel: +SELECT * FROM local_channels WHERE channel_id=?; + +updateChannel: +UPDATE local_channels SET data=? WHERE channel_id=?; + +insertChannel: +INSERT INTO local_channels VALUES (?, ?, 0); + +closeLocalChannel: +UPDATE local_channels SET is_closed=1 WHERE channel_id=?; + +listLocalChannels: +SELECT data FROM local_channels WHERE is_closed=0; + +-- htlcs info queries +insertHtlcInfo: +INSERT INTO htlc_infos VALUES (?, ?, ?, ?); + +listHtlcInfos: +SELECT payment_hash, cltv_expiry FROM htlc_infos WHERE channel_id=? AND commitment_number=?; + +deleteHtlcInfo: +DELETE FROM htlc_infos WHERE channel_id=?; \ No newline at end of file diff --git a/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/CloudKitContacts.sq b/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/CloudKitContacts.sq new file mode 100644 index 00000000..a395a320 --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/CloudKitContacts.sq @@ -0,0 +1,85 @@ + + +-- This table stores the CKRecord metadata corresponding to a synced contact. +-- * id => stores the primary key of the contact row +-- +CREATE TABLE IF NOT EXISTS cloudkit_contacts_metadata ( + id TEXT NOT NULL PRIMARY KEY, + record_creation INTEGER NOT NULL, + record_blob BLOB NOT NULL +); + +-- When resuming the download process (e.g. after app relaunch), +-- we need to fetch the earliest creationDate. +CREATE INDEX IF NOT EXISTS record_creation_idx +ON cloudkit_contacts_metadata(record_creation); + +-- This table stores the queue of items that need to be pushed to the cloud. +-- * rowid => because we might store the same `id` multiple times +-- * id => stores the primary key of the contact row +-- +CREATE TABLE IF NOT EXISTS cloudkit_contacts_queue ( + rowid INTEGER PRIMARY KEY, + id TEXT NOT NULL, + date_added INTEGER NOT NULL +); + +-- ########## cloudkit_contacts_metadata ########## + +addMetadata: +INSERT INTO cloudkit_contacts_metadata ( + id, + record_creation, + record_blob) +VALUES (?, ?, ?); + +updateMetadata: +UPDATE cloudkit_contacts_metadata +SET record_blob = ? +WHERE id = ?; + +existsMetadata: +SELECT COUNT(*) FROM cloudkit_contacts_metadata +WHERE id = ?; + +fetchMetadata: +SELECT * FROM cloudkit_contacts_metadata +WHERE id = ?; + +scanMetadata: +SELECT id FROM cloudkit_contacts_metadata; + +fetchOldestCreation_Contacts: +SELECT id, record_creation FROM cloudkit_contacts_metadata +ORDER BY record_creation ASC +LIMIT 1; + +deleteMetadata: +DELETE FROM cloudkit_contacts_metadata +WHERE id = ?; + +deleteAllFromMetadata: +DELETE FROM cloudkit_contacts_metadata; + +-- ########## cloudkit_contacts_queue ########## + +addToQueue: +INSERT INTO cloudkit_contacts_queue ( + id, + date_added) +VALUES (?, ?); + +fetchQueueBatch: +SELECT * FROM cloudkit_contacts_queue +ORDER BY date_added ASC +LIMIT :limit; + +fetchQueueCount: +SELECT COUNT(*) FROM cloudkit_contacts_queue; + +deleteFromQueue: +DELETE FROM cloudkit_contacts_queue +WHERE rowid = ?; + +deleteAllFromQueue: +DELETE FROM cloudkit_contacts_queue; diff --git a/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/CloudKitPayments.sq b/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/CloudKitPayments.sq new file mode 100644 index 00000000..5ec6694c --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/CloudKitPayments.sq @@ -0,0 +1,90 @@ +import fr.acinq.lightning.utils.UUID; + +-- This table stores the CKRecord metadata corresponding to a synced payment. +-- * id => stores the primary key of the payment row +-- +CREATE TABLE IF NOT EXISTS cloudkit_payments_metadata ( + id BLOB AS UUID NOT NULL PRIMARY KEY, + unpadded_size INTEGER NOT NULL, + record_creation INTEGER NOT NULL, + record_blob BLOB NOT NULL +); + +-- When resuming the download process (e.g. after app relaunch), +-- we need to fetch the earliest creationDate. +CREATE INDEX IF NOT EXISTS record_creation_idx +ON cloudkit_payments_metadata(record_creation); + +-- This table stores the queue of items that need to be pushed to the cloud. +-- * id => stores the primary key of the payment row +-- +CREATE TABLE IF NOT EXISTS cloudkit_payments_queue ( + rowid INTEGER PRIMARY KEY, + id BLOB AS UUID NOT NULL, + date_added INTEGER NOT NULL +); + +-- ########## cloudkit_payments_metadata ########## + +addMetadata: +INSERT INTO cloudkit_payments_metadata ( + id, + unpadded_size, + record_creation, + record_blob) +VALUES (?, ?, ?, ?); + +updateMetadata: +UPDATE cloudkit_payments_metadata +SET unpadded_size = ?, + record_blob = ? +WHERE id = ?; + +existsMetadata: +SELECT COUNT(*) FROM cloudkit_payments_metadata +WHERE id = ?; + +fetchMetadata: +SELECT * FROM cloudkit_payments_metadata +WHERE id = ?; + +scanMetadata: +SELECT id FROM cloudkit_payments_metadata; + +listNonZeroSizes: +SELECT id, unpadded_size FROM cloudkit_payments_metadata WHERE unpadded_size > 0; + +fetchOldestCreation: +SELECT id, record_creation FROM cloudkit_payments_metadata +ORDER BY record_creation ASC +LIMIT 1; + +deleteMetadata: +DELETE FROM cloudkit_payments_metadata +WHERE id = ?; + +deleteAllFromMetadata: +DELETE FROM cloudkit_payments_metadata; + +-- ########## cloudkit_payments_queue ########## + +addToQueue: +INSERT INTO cloudkit_payments_queue ( + id, + date_added) +VALUES (?, ?); + +fetchQueueBatch: +SELECT * FROM cloudkit_payments_queue +ORDER BY date_added ASC +LIMIT :limit; + +fetchQueueCount: +SELECT COUNT(*) FROM cloudkit_payments_queue; + +deleteFromQueue: +DELETE FROM cloudkit_payments_queue +WHERE rowid = ?; + +deleteAllFromQueue: +DELETE FROM cloudkit_payments_queue; \ No newline at end of file diff --git a/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/Contacts.sq b/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/Contacts.sq new file mode 100644 index 00000000..99fb1b85 --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/Contacts.sq @@ -0,0 +1,36 @@ +import fr.acinq.lightning.utils.UUID; +import fr.acinq.phoenix.data.ContactInfo; + +CREATE TABLE IF NOT EXISTS contacts ( + id BLOB AS UUID NOT NULL PRIMARY KEY, + data BLOB AS ContactInfo NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER DEFAULT NULL +); + +listContacts: +SELECT data +FROM contacts; + +getContact: +SELECT data +FROM contacts +WHERE id = :contactId; + +scanContacts: +SELECT id, created_at FROM contacts; + +existsContact: +SELECT COUNT(*) FROM contacts +WHERE id = ?; + +insertContact: +INSERT INTO contacts(id, data, created_at, updated_at) +VALUES (:id, :data, :createdAt, :updatedAt); + +updateContact: +UPDATE contacts SET data=:data, updated_at=:updatedAt +WHERE id=:contactId; + +deleteContact: +DELETE FROM contacts WHERE id=:contactId; diff --git a/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/OnChainTransactions.sq b/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/OnChainTransactions.sq new file mode 100644 index 00000000..99436bf8 --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/OnChainTransactions.sq @@ -0,0 +1,35 @@ +import fr.acinq.bitcoin.ByteVector32; +import fr.acinq.bitcoin.TxId; +import fr.acinq.lightning.utils.UUID; + +CREATE TABLE on_chain_txs ( + payment_id BLOB AS UUID NOT NULL PRIMARY KEY, + tx_id BLOB AS TxId NOT NULL, + confirmed_at INTEGER, + locked_at INTEGER +); + +CREATE INDEX on_chain_txs_tx_id ON on_chain_txs(tx_id); + +insert: +INSERT INTO on_chain_txs( + payment_id, + tx_id, + confirmed_at, + locked_at) +VALUES (?, ?, ?, ?); + +setConfirmed: +UPDATE on_chain_txs +SET confirmed_at=? +WHERE tx_id=?; + +setLocked: +UPDATE on_chain_txs +SET locked_at=? +WHERE tx_id=?; + +listUnconfirmed: +SELECT tx_id +FROM on_chain_txs +WHERE confirmed_at IS NULL; diff --git a/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/Payments.sq b/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/Payments.sq new file mode 100644 index 00000000..4d88e36e --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/Payments.sq @@ -0,0 +1,59 @@ +-- This view returns all outgoing payments, but only successful incoming payments. +CREATE VIEW payments +AS SELECT id, created_at, completed_at, succeeded_at, order_ts, data +FROM ( + SELECT id, created_at, received_at AS completed_at, received_at AS succeeded_at, received_at AS order_ts, data + FROM payments_incoming + WHERE received_at IS NOT NULL -- we only consider completed incoming payments +UNION ALL + SELECT id, created_at, completed_at, succeeded_at, created_at AS order_ts, data + FROM payments_outgoing +); + +get: +SELECT data +FROM payments +WHERE id=:id; + +list: +SELECT p.data, pm.* +FROM payments AS p +LEFT OUTER JOIN payments_metadata AS pm ON pm.payment_id = p.id +ORDER BY order_ts DESC +LIMIT :limit OFFSET :offset; + +listSuccessful: +SELECT p.data, pm.* +FROM payments AS p +LEFT OUTER JOIN payments_metadata AS pm ON pm.payment_id = p.id +WHERE + succeeded_at BETWEEN :succeeded_at_from AND :succeeded_at_to +ORDER BY order_ts +LIMIT :limit OFFSET :offset; + +listInFlight: +SELECT p.data, pm.* +FROM payments AS p +LEFT OUTER JOIN payments_metadata AS pm ON pm.payment_id = p.id +WHERE + completed_at IS NULL +ORDER BY order_ts DESC +LIMIT :limit OFFSET :offset; + +listRecent: +SELECT p.data, pm.* +FROM payments AS p +LEFT OUTER JOIN payments_metadata AS pm ON pm.payment_id = p.id +WHERE + order_ts >= :min_ts +ORDER BY order_ts DESC +LIMIT :limit OFFSET :offset; + +getOldestCompletedAt: +SELECT min(completed_at) AS completed_at +FROM payments; + +countCompletedInRange: +SELECT count(id) +FROM payments +WHERE completed_at BETWEEN :completed_at_from AND :completed_at_to; diff --git a/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsIncoming.sq b/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsIncoming.sq new file mode 100644 index 00000000..6bf0c623 --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsIncoming.sq @@ -0,0 +1,90 @@ +import fr.acinq.bitcoin.ByteVector32; +import fr.acinq.bitcoin.TxId; +import fr.acinq.lightning.db.IncomingPayment; +import fr.acinq.lightning.utils.UUID; + +-- incoming payments +CREATE TABLE payments_incoming ( + id BLOB AS UUID NOT NULL PRIMARY KEY, + payment_hash BLOB AS ByteVector32 UNIQUE, + tx_id BLOB AS TxId, + created_at INTEGER NOT NULL, + received_at INTEGER, + data BLOB AS IncomingPayment NOT NULL +); + +CREATE INDEX payments_incoming_payment_hash_idx ON payments_incoming(payment_hash); +CREATE INDEX payments_incoming_tx_id_idx ON payments_incoming(tx_id); + +-- Create indexes to optimize the queries in AggregatedQueries. +-- Tip: Use "explain query plan" to ensure they're actually being used. +CREATE INDEX payments_incoming_filter_idx + ON payments_incoming(received_at) + WHERE received_at IS NOT NULL; + +-- queries + +insert: +INSERT INTO payments_incoming ( + id, + payment_hash, + tx_id, + created_at, + received_at, + data) +VALUES (?, ?, ?, ?, ?, ?); + +update: +UPDATE payments_incoming +SET received_at=:receivedAt, + tx_id=:txId, + data=:data +WHERE id=:id; + +get: +SELECT data +FROM payments_incoming +WHERE id=?; + +getByPaymentHash: +SELECT data +FROM payments_incoming +WHERE payment_hash=?; + +list: +SELECT payment.data +FROM payments_incoming AS payment +WHERE + payment.created_at BETWEEN :created_at_from AND :created_at_to +ORDER BY payment.created_at DESC +LIMIT :limit OFFSET :offset; + +listSuccessful: +SELECT payment.data +FROM payments_incoming AS payment +WHERE + payment.received_at BETWEEN :received_at_from AND :received_at_to +ORDER BY payment.received_at DESC +LIMIT :limit OFFSET :offset; + +listByTxId: +SELECT data +FROM payments_incoming +WHERE tx_id=?; + +listSuccessfulIds: +SELECT id, received_at +FROM payments_incoming +WHERE received_at IS NOT NULL; + +deleteById: +DELETE FROM payments_incoming +WHERE id = ?; + +deleteByPaymentHash: +DELETE FROM payments_incoming +WHERE payment_hash = ?; + +-- use this in a `transaction` block to know how many rows were changed after an UPDATE +changes: +SELECT changes(); diff --git a/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsMetadata.sq b/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsMetadata.sq new file mode 100644 index 00000000..169e30ad --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsMetadata.sq @@ -0,0 +1,65 @@ +import fr.acinq.phoenix.db.payments.LnurlBase; +import fr.acinq.phoenix.db.payments.LnurlMetadata; +import fr.acinq.phoenix.db.payments.LnurlSuccessAction; +import fr.acinq.lightning.utils.UUID; + +-- This table stores metadata corresponding to a payment. +-- * payment_id => uuid of an incoming or an outgoing payments +-- * lnurl_base => serialized lnurl object (e.g. LNUrl.Pay), excluding metadata content +-- * lnurl_metadata => serialized lnurl metadata (e.g. LNUrl.Pay.Metadata) +-- * lnurl_successAction => serialized LUD-09 (e.g. LNUrl.PayInvoice.SuccessAction.Message) +-- * user_description => user-customized short description +-- * user_notes => user-customized notes (can be much longer than description) +-- * modified_at => last time this DB entry was modified (i.e. within payments_metadata table) +-- * original_fiat => stores original fiat price (via conversion rate) at time of transaction +-- +CREATE TABLE IF NOT EXISTS payments_metadata ( + payment_id BLOB AS UUID NOT NULL PRIMARY KEY, + lnurl_base_type TEXT AS LnurlBase.TypeVersion, + lnurl_base_blob BLOB, + lnurl_description TEXT, + lnurl_metadata_type TEXT AS LnurlMetadata.TypeVersion, + lnurl_metadata_blob BLOB, + lnurl_successAction_type TEXT AS LnurlSuccessAction.TypeVersion, + lnurl_successAction_blob BLOB, + user_description TEXT, + user_notes TEXT, + modified_at INTEGER, + original_fiat_type TEXT, + original_fiat_rate REAL, + lightning_address TEXT +); + +-- queries for payments_metadata table + +hasMetadata: +SELECT COUNT(*) FROM payments_metadata +WHERE payment_id = ?; + +addMetadata: +INSERT INTO payments_metadata ( + payment_id, + lnurl_base_type, lnurl_base_blob, + lnurl_description, + lnurl_metadata_type, lnurl_metadata_blob, + lnurl_successAction_type, lnurl_successAction_blob, + user_description, user_notes, + modified_at, + original_fiat_type, original_fiat_rate, + lightning_address) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + +updateUserInfo: +UPDATE payments_metadata +SET user_description = ?, + user_notes = ?, + modified_at = ? +WHERE payment_id = ?; + +get: +SELECT * FROM payments_metadata +WHERE payment_id = ?; + +-- use this in a `transaction` block to know how many rows were changed after an UPDATE +changes: +SELECT changes(); \ No newline at end of file diff --git a/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsOutgoing.sq b/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsOutgoing.sq new file mode 100644 index 00000000..95fd45fe --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsOutgoing.sq @@ -0,0 +1,99 @@ +import fr.acinq.bitcoin.ByteVector32; +import fr.acinq.bitcoin.TxId; +import fr.acinq.lightning.db.OutgoingPayment; +import fr.acinq.lightning.utils.UUID; + +CREATE TABLE payments_outgoing ( + id BLOB AS UUID NOT NULL PRIMARY KEY, + payment_hash BLOB AS ByteVector32, + tx_id BLOB AS TxId, + created_at INTEGER NOT NULL, + completed_at INTEGER, -- a completed outgoing payment can be a success or a failure + succeeded_at INTEGER, -- will only be set for a successful payment + data BLOB AS OutgoingPayment NOT NULL +); + +CREATE INDEX payments_outgoing_payment_hash_idx ON payments_outgoing(payment_hash); +CREATE INDEX payments_outgoing_tx_id_idx ON payments_outgoing(tx_id); + +-- Create indexes to optimize the queries in AggregatedQueries. +-- Tip: Use "explain query plan" to ensure they're actually being used. +CREATE INDEX payments_outgoing_filter_idx ON payments_outgoing(completed_at) WHERE completed_at IS NOT NULL; + +CREATE TABLE link_lightning_outgoing_payment_parts ( + part_id BLOB AS UUID NOT NULL PRIMARY KEY, + parent_id BLOB AS UUID NOT NULL +); + +-- queries + +insert: +INSERT INTO payments_outgoing ( + id, + payment_hash, + tx_id, + created_at, + completed_at, + succeeded_at, + data) +VALUES (?, ?, ?, ?, ?, ?, ?); + +insertPartLink: +INSERT INTO link_lightning_outgoing_payment_parts( + part_id, + parent_id) +VALUES (?, ?); + +update: +UPDATE payments_outgoing +SET completed_at=?, + succeeded_at=?, + data=:data +WHERE id = :id; + +get: +SELECT data +FROM payments_outgoing +WHERE id=?; + +getParentId: +SELECT parent_id +FROM link_lightning_outgoing_payment_parts +WHERE part_id=?; + +listByPaymentHash: +SELECT data +FROM payments_outgoing +WHERE payment_hash=?; + +listByTxId: +SELECT data +FROM payments_outgoing +WHERE tx_id=?; + +deleteById: +DELETE FROM payments_outgoing +WHERE id = ?; + +deleteByPaymentHash: +DELETE FROM payments_outgoing +WHERE payment_hash = ?; + +list: +SELECT data +FROM payments_outgoing +WHERE + created_at BETWEEN :created_at_from AND :created_at_to +LIMIT :limit OFFSET :offset; + +listSuccessful: +SELECT data +FROM payments_outgoing +WHERE + succeeded_at BETWEEN :succeeded_at_from AND :succeeded_at_to +LIMIT :limit OFFSET :offset; + +listSuccessfulIds: +SELECT id, completed_at +FROM payments_outgoing +WHERE completed_at IS NOT NULL; diff --git a/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/1.sqm b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/1.sqm new file mode 100644 index 00000000..e55a7b65 --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/1.sqm @@ -0,0 +1,24 @@ +-- Migration: v1 -> v2 +-- +-- Changes: +-- * Added table cloudkit_payments_metadata +-- * Added table cloudkit_payments_queue + +CREATE TABLE IF NOT EXISTS cloudkit_payments_metadata ( + type INTEGER NOT NULL, + id TEXT NOT NULL, + unpadded_size INTEGER NOT NULL, + record_creation INTEGER NOT NULL, + record_blob BLOB NOT NULL, + PRIMARY KEY (type, id) +); + +CREATE INDEX IF NOT EXISTS record_creation_idx +ON cloudkit_payments_metadata(record_creation); + +CREATE TABLE IF NOT EXISTS cloudkit_payments_queue ( + rowid INTEGER PRIMARY KEY, + type INTEGER NOT NULL, + id TEXT NOT NULL, + date_added INTEGER NOT NULL +); \ No newline at end of file diff --git a/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/10.sqm b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/10.sqm new file mode 100644 index 00000000..c5ad24ae --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/10.sqm @@ -0,0 +1,22 @@ +-- Migration: v10 -> v11 +-- +-- This is a code migration, see AfterVersion10.kt + +-- incoming payments +CREATE TABLE payments_incoming ( + id BLOB AS UUID NOT NULL PRIMARY KEY, + payment_hash BLOB AS ByteVector32 UNIQUE, + tx_id BLOB AS TxId, + created_at INTEGER NOT NULL, + received_at INTEGER, + data BLOB AS IncomingPayment NOT NULL +); + +CREATE INDEX payments_incoming_payment_hash_idx ON payments_incoming(payment_hash); +CREATE INDEX payments_incoming_tx_id_idx ON payments_incoming(tx_id); + +-- Create indexes to optimize the queries in AggregatedQueries. +-- Tip: Use "explain query plan" to ensure they're actually being used. +CREATE INDEX payments_incoming_filter_idx + ON payments_incoming(received_at) + WHERE received_at IS NOT NULL; \ No newline at end of file diff --git a/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/11.sqm b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/11.sqm new file mode 100644 index 00000000..2cc67550 --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/11.sqm @@ -0,0 +1,92 @@ +-- Migration: v11 -> v12 +-- +-- Changes: +-- * add table payments_outgoing, which stores all types of outgoing payments. +-- * add table link_lightning_outgoing_payment_parts, which links lightning parts with the parent. +-- +-- /!\ There is also a code migration, see AfterVersion11.kt. +-- This code migration moves existing data to the new tables and drops the old tables. + +CREATE TABLE payments_outgoing ( + id BLOB AS UUID NOT NULL PRIMARY KEY, + payment_hash BLOB AS ByteVector32, + tx_id BLOB AS TxId, + created_at INTEGER NOT NULL, + completed_at INTEGER, -- a completed outgoing payment can be a success or a failure + succeeded_at INTEGER, -- will only be set for a successful payment + data BLOB AS OutgoingPayment NOT NULL +); + +CREATE INDEX payments_outgoing_payment_hash_idx ON payments_outgoing(payment_hash); +CREATE INDEX payments_outgoing_tx_id_idx ON payments_outgoing(tx_id); + +-- Create indexes to optimize the queries in AggregatedQueries. +-- Tip: Use "explain query plan" to ensure they're actually being used. +CREATE INDEX payments_outgoing_filter_idx ON payments_outgoing(completed_at) WHERE completed_at IS NOT NULL; + +CREATE TABLE link_lightning_outgoing_payment_parts ( + part_id BLOB AS UUID NOT NULL PRIMARY KEY, + parent_id BLOB AS UUID NOT NULL +); + +-- This table will be read then deleted in the code migration +ALTER TABLE payments_metadata RENAME TO payments_metadata_old; + +CREATE TABLE IF NOT EXISTS payments_metadata ( + payment_id BLOB AS UUID NOT NULL PRIMARY KEY, + lnurl_base_type TEXT AS LnurlBase.TypeVersion, + lnurl_base_blob BLOB, + lnurl_description TEXT, + lnurl_metadata_type TEXT AS LnurlMetadata.TypeVersion, + lnurl_metadata_blob BLOB, + lnurl_successAction_type TEXT AS LnurlSuccessAction.TypeVersion, + lnurl_successAction_blob BLOB, + user_description TEXT, + user_notes TEXT, + modified_at INTEGER, + original_fiat_type TEXT, + original_fiat_rate REAL +); + +CREATE TABLE on_chain_txs ( + payment_id BLOB AS UUID NOT NULL PRIMARY KEY, + tx_id BLOB AS TxId NOT NULL, + confirmed_at INTEGER, + locked_at INTEGER +); + +CREATE INDEX on_chain_txs_tx_id ON on_chain_txs(tx_id); + +CREATE VIEW payments +AS SELECT id, created_at, completed_at, succeeded_at, order_ts, data +FROM ( + SELECT id, created_at, received_at AS completed_at, received_at AS succeeded_at, received_at AS order_ts, data + FROM payments_incoming + WHERE received_at IS NOT NULL -- we only consider completed incoming payments +UNION ALL + SELECT id, created_at, completed_at, succeeded_at, created_at AS order_ts, data + FROM payments_outgoing +); + +-- This table will be read then deleted in the code migration +ALTER TABLE cloudkit_payments_metadata RENAME TO cloudkit_payments_metadata_old; +DROP INDEX record_creation_idx; + +CREATE TABLE IF NOT EXISTS cloudkit_payments_metadata ( + id BLOB AS UUID NOT NULL PRIMARY KEY, + unpadded_size INTEGER NOT NULL, + record_creation INTEGER NOT NULL, + record_blob BLOB NOT NULL +); + +CREATE INDEX IF NOT EXISTS record_creation_idx + ON cloudkit_payments_metadata(record_creation); + +-- This table will be read then deleted in the code migration +ALTER TABLE cloudkit_payments_queue RENAME TO cloudkit_payments_queue_old; +CREATE TABLE IF NOT EXISTS cloudkit_payments_queue ( + rowid INTEGER PRIMARY KEY, + id BLOB AS UUID NOT NULL, + date_added INTEGER NOT NULL +); + diff --git a/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/12.sqm b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/12.sqm new file mode 100644 index 00000000..5eb8b13b --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/12.sqm @@ -0,0 +1,37 @@ +-- Migration: v12 -> v13 +-- +-- Changes: +-- * Added a new column [lightning_address] in table [payments_metadata] +-- * Migration of contacts from appDb to paymentsDb: +-- * Added new table: contacts +-- * Added new table: cloudkit_contacts_metadata +-- * Added new table: cloudkit_contacts_queue +-- * Added new index: record_creation_idx + +import fr.acinq.lightning.utils.UUID; +import fr.acinq.phoenix.data.ContactInfo; + +ALTER TABLE payments_metadata + ADD COLUMN lightning_address TEXT; + +CREATE TABLE IF NOT EXISTS contacts ( + id BLOB AS UUID NOT NULL PRIMARY KEY, + data BLOB AS ContactInfo NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER DEFAULT NULL +); + +CREATE TABLE IF NOT EXISTS cloudkit_contacts_metadata ( + id TEXT NOT NULL PRIMARY KEY, + record_creation INTEGER NOT NULL, + record_blob BLOB NOT NULL +); + +CREATE INDEX IF NOT EXISTS record_creation_idx +ON cloudkit_contacts_metadata(record_creation); + +CREATE TABLE IF NOT EXISTS cloudkit_contacts_queue ( + rowid INTEGER PRIMARY KEY, + id TEXT NOT NULL, + date_added INTEGER NOT NULL +); \ No newline at end of file diff --git a/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/2.sqm b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/2.sqm new file mode 100644 index 00000000..ff70e485 --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/2.sqm @@ -0,0 +1,22 @@ +-- Migration: v2 -> v3 +-- +-- Changes: +-- * Added table payments_metadata + +import fr.acinq.phoenix.db.payments.LnurlBase; +import fr.acinq.phoenix.db.payments.LnurlMetadata; +import fr.acinq.phoenix.db.payments.LnurlSuccessAction; + +CREATE TABLE IF NOT EXISTS payments_metadata ( + type INTEGER NOT NULL, + id TEXT NOT NULL, + lnurl_base_type TEXT AS LnurlBase.TypeVersion, + lnurl_base_blob BLOB, + lnurl_description TEXT, + lnurl_metadata_type TEXT AS LnurlMetadata.TypeVersion, + lnurl_metadata_blob BLOB, + lnurl_successAction_type TEXT AS LnurlSuccessAction.TypeVersion, + lnurl_successAction_blob BLOB, + user_description TEXT, + PRIMARY KEY (type, id) +); diff --git a/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/3.sqm b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/3.sqm new file mode 100644 index 00000000..f7e337f4 --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/3.sqm @@ -0,0 +1,12 @@ +-- Migration: v3 -> v4 +-- +-- Changes: +-- * Added column: payments_metadata.user_notes +-- * Added column: payments_metadata.modified_at +-- * Added indexes to improve performance of listAllPaymentsOrder query + +ALTER TABLE payments_metadata ADD COLUMN user_notes TEXT DEFAULT NULL; +ALTER TABLE payments_metadata ADD COLUMN modified_at INTEGER DEFAULT NULL; + +CREATE INDEX IF NOT EXISTS incoming_payments_created_at_idx ON incoming_payments(created_at); +CREATE INDEX IF NOT EXISTS outgoing_payments_created_at_idx ON outgoing_payments(created_at); diff --git a/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/4.sqm b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/4.sqm new file mode 100644 index 00000000..24f6ac84 --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/4.sqm @@ -0,0 +1,8 @@ +-- Migration: v4 -> v5 +-- +-- Changes: +-- * Added column: payments_metadata.original_fiat_type +-- * Added column: payments_metadata.original_fiat_rate + +ALTER TABLE payments_metadata ADD COLUMN original_fiat_type TEXT DEFAULT NULL; +ALTER TABLE payments_metadata ADD COLUMN original_fiat_rate REAL DEFAULT NULL; diff --git a/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/5.sqm b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/5.sqm new file mode 100644 index 00000000..27257efc --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/5.sqm @@ -0,0 +1,22 @@ +import fr.acinq.phoenix.db.payments.OutgoingPartClosingInfoTypeVersion; + +-- Migration: v5 -> v6 +-- +-- Changes: +-- * Add a new table that stores the transactions closing a lightning channel, represented +-- as parts of an outgoing payment. +-- * Add an index on parent_id in the closing txs table. + +CREATE TABLE IF NOT EXISTS outgoing_payment_closing_tx_parts ( + part_id TEXT NOT NULL PRIMARY KEY, + part_parent_id TEXT NOT NULL, + part_tx_id BLOB NOT NULL, + part_amount_sat INTEGER NOT NULL, + part_closing_info_type TEXT AS OutgoingPartClosingInfoTypeVersion NOT NULL, + part_closing_info_blob BLOB NOT NULL, + part_created_at INTEGER NOT NULL, + + FOREIGN KEY(part_parent_id) REFERENCES outgoing_payments(id) +); + +CREATE INDEX IF NOT EXISTS parent_id_idx ON outgoing_payment_closing_tx_parts(part_parent_id); \ No newline at end of file diff --git a/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/6.sqm b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/6.sqm new file mode 100644 index 00000000..202ca77e --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/6.sqm @@ -0,0 +1,17 @@ +-- Migration: v6 -> v7 +-- +-- Changes: +-- * Removed index: received_amount_msat_idx +-- * Replaced index: incoming_payments_created_at_idx -> incoming_payments_filter_idx +-- * Replaced index: outgoing_payments_created_at_idx -> outgoing_payments_filter_idx + +DROP INDEX IF EXISTS received_amount_msat_idx; + +DROP INDEX IF EXISTS incoming_payments_created_at_idx; +CREATE INDEX IF NOT EXISTS incoming_payments_filter_idx + ON incoming_payments(received_at) + WHERE received_at IS NOT NULL; + +DROP INDEX IF EXISTS outgoing_payments_created_at_idx; +CREATE INDEX IF NOT EXISTS outgoing_payments_filter_idx + ON outgoing_payments(completed_at); diff --git a/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/7.sqm b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/7.sqm new file mode 100644 index 00000000..239b0e4c --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/7.sqm @@ -0,0 +1,57 @@ +import fr.acinq.phoenix.db.payments.OutgoingPartClosingInfoTypeVersion; + +-- Migration: v7 -> v8 +-- +-- Changes: +-- * add a new splice_outgoing_payments table to store outgoing splices +-- * add a new channel_close_outgoing_payments table to store channel closings +-- * add a new link_tx_to_payments table that links payments to on-chain transactions +-- * add a new expected_amount_msat column in the incoming_payments table + +CREATE TABLE IF NOT EXISTS splice_outgoing_payments ( + id TEXT NOT NULL PRIMARY KEY, + recipient_amount_sat INTEGER NOT NULL, + address TEXT NOT NULL, + mining_fees_sat INTEGER NOT NULL, + tx_id BLOB NOT NULL, + channel_id BLOB NOT NULL, + created_at INTEGER NOT NULL, + confirmed_at INTEGER DEFAULT NULL, + locked_at INTEGER DEFAULT NULL +); + +CREATE TABLE IF NOT EXISTS channel_close_outgoing_payments ( + id TEXT NOT NULL PRIMARY KEY, + recipient_amount_sat INTEGER NOT NULL, + address TEXT NOT NULL, + is_default_address INTEGER NOT NULL, + mining_fees_sat INTEGER NOT NULL, + tx_id BLOB NOT NULL, + created_at INTEGER NOT NULL, + confirmed_at INTEGER DEFAULT NULL, + locked_at INTEGER DEFAULT NULL, + channel_id BLOB NOT NULL, + closing_info_type TEXT AS OutgoingPartClosingInfoTypeVersion NOT NULL, + closing_info_blob BLOB NOT NULL +); + +CREATE TABLE IF NOT EXISTS link_tx_to_payments ( + tx_id BLOB NOT NULL, + type INTEGER NOT NULL, + id TEXT NOT NULL, + confirmed_at INTEGER DEFAULT NULL, + locked_at INTEGER DEFAULT NULL, + PRIMARY KEY (tx_id, type, id) +); + +CREATE TABLE IF NOT EXISTS splice_cpfp_outgoing_payments ( + id TEXT NOT NULL PRIMARY KEY, + mining_fees_sat INTEGER NOT NULL, + channel_id BLOB NOT NULL, + tx_id BLOB NOT NULL, + created_at INTEGER NOT NULL, + confirmed_at INTEGER DEFAULT NULL, + locked_at INTEGER DEFAULT NULL +); + +CREATE INDEX IF NOT EXISTS link_tx_to_payments_txid ON link_tx_to_payments(tx_id); diff --git a/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/8.sqm b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/8.sqm new file mode 100644 index 00000000..26f5e8fa --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/8.sqm @@ -0,0 +1,18 @@ +import fr.acinq.phoenix.db.payments.InboundLiquidityLeaseTypeVersion; + +-- Migration: v8 -> v9 +-- +-- Changes: +-- * add a new inbound_liquidity_outgoing_payments table to store inbound liquidity payments + +CREATE TABLE IF NOT EXISTS inbound_liquidity_outgoing_payments ( + id TEXT NOT NULL PRIMARY KEY, + mining_fees_sat INTEGER NOT NULL, + channel_id BLOB NOT NULL, + tx_id BLOB NOT NULL, + lease_type TEXT AS InboundLiquidityLeaseTypeVersion NOT NULL, + lease_blob BLOB NOT NULL, + created_at INTEGER NOT NULL, + confirmed_at INTEGER DEFAULT NULL, + locked_at INTEGER DEFAULT NULL +); diff --git a/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/9.sqm b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/9.sqm new file mode 100644 index 00000000..0b8973f6 --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/paymentsdb/migrations/9.sqm @@ -0,0 +1,8 @@ +import fr.acinq.phoenix.db.payments.InboundLiquidityLeaseTypeVersion; + +-- Migration: v9 -> v10 +-- +-- Changes: +-- * Added a new column [payment_details_type] in table [inbound_liquidity_outgoing_payments] + +ALTER TABLE inbound_liquidity_outgoing_payments ADD COLUMN payment_details_type TEXT DEFAULT NULL; diff --git a/composeApp/src/iosMain/kotlin/ac/cord/auxiliary/compose/AppVersion.ios.kt b/composeApp/src/iosMain/kotlin/ac/cord/auxiliary/compose/AppVersion.ios.kt new file mode 100644 index 00000000..76ee41cb --- /dev/null +++ b/composeApp/src/iosMain/kotlin/ac/cord/auxiliary/compose/AppVersion.ios.kt @@ -0,0 +1,13 @@ +package ac.cord.auxiliary.compose + +import platform.Foundation.NSBundle + +actual object AppVersion { + val serviceName: String = "Machankura" + val accessGroup: String? = null + + actual val versionName: String + get() = (NSBundle.mainBundle.infoDictionary?.get("CFBundleShortVersionString") as String?) ?: "Unknown" + actual val versionCode: String + get() = (NSBundle.mainBundle.infoDictionary?.get("CFBundleVersion") as String?) ?: "0" +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/data/ElectrumServers.ios.kt b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/data/ElectrumServers.ios.kt new file mode 100644 index 00000000..8e918e66 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/data/ElectrumServers.ios.kt @@ -0,0 +1,6 @@ +package fr.acinq.phoenix.data + +import fr.acinq.lightning.io.TcpSocket +import fr.acinq.lightning.utils.ServerAddress + +actual fun platformElectrumRegtestConf(): ServerAddress = ServerAddress(host = "127.0.0.1", port = 51002, TcpSocket.TLS.DISABLED) \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitContactsDb.kt b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitContactsDb.kt new file mode 100644 index 00000000..86f67a4d --- /dev/null +++ b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitContactsDb.kt @@ -0,0 +1,313 @@ +package fr.acinq.phoenix.db + +import app.cash.sqldelight.Transacter +import app.cash.sqldelight.coroutines.asFlow +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.currentTimestampMillis +import fr.acinq.phoenix.data.ContactInfo +import kotlin.collections.List +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +class CloudKitContactsDb( + private val paymentsDb: SqlitePaymentsDb +): CoroutineScope by MainScope() { + + private val db: Transacter = paymentsDb.database + private val queries = paymentsDb.database.cloudKitContactsQueries + + /** + * Provides a flow of the count of items within the cloudkit_contacts_queue table. + */ + private val _queueCount = MutableStateFlow(0) + val queueCount: StateFlow = _queueCount.asStateFlow() + + data class MetadataRow( + val recordCreation: Long, + val recordBlob: ByteArray + ) + + data class MissingItem( + val contactId: UUID, + val timestamp: Long + ) + + data class FetchQueueBatchResult( + + // The fetched rowid values from the `cloudkit_contacts_queue` table + val rowids: List, + + // Maps `cloudkit_contacts_queue.rowid` to the corresponding ContactId. + // If missing from the map, then the `cloudkit_contacts_queue` row was + // malformed or unrecognized. + val rowidMap: Map, + + // Maps to the contact information in the database. + // If missing from the map, then the contacts has been deleted from the database. + val rowMap: Map, + + // Maps to `cloudkit_contacts_metadata.ckrecord_info`. + // If missing from the map, then then record doesn't exist in the database. + val metadataMap: Map, + ) + + init { + // N.B.: There appears to be a subtle bug in SQLDelight's + // `.asFlow().mapToX()`, as described here: + // https://github.com/ACINQ/phoenix/pull/415 + launch { + queries.fetchQueueCount() + .asFlow() + .map { + withContext(Dispatchers.Default) { + db.transactionWithResult { + it.executeAsOne() + } + } + } + .collect { count -> + _queueCount.value = count + } + } + } + + suspend fun fetchQueueBatch(limit: Long): FetchQueueBatchResult { + return withContext(Dispatchers.Default) { + + val rowids = mutableListOf() + val rowidMap = mutableMapOf() + val rowMap = mutableMapOf() + val metadataMap = mutableMapOf() + + db.transaction { + + // Step 1 of 3: + // Fetch the rows from the `cloudkit_contacts_queue` batch. + // We are fetching the next/oldest X rows from the queue. + + val batch = queries.fetchQueueBatch(limit).executeAsList() + + // Step 2 of 3: + // Process the batch, and fill out the `rowids` & `rowidMap` variable. + + batch.forEach { row -> + rowids.add(row.rowid) + try { + val contactId = row.id + rowidMap[row.rowid] = UUID.fromString(contactId) + } catch (e: Exception) { + // UUID appears to be malformed within the database. + // Nothing we can do here - but let's at least not crash. + } + } // + + // Remember: there could be duplicates + val uniqueContactIds = rowidMap.values.toSet() + + // Step 3 of 3: + // Fetch the corresponding contact info from the database. + + uniqueContactIds.forEach { contactId -> + if (paymentsDb.contacts.contactQueries.existsContact(contactId)) { + // appDb.contactQueries.getContact() throws if the contact + // doesn't exist in database. + paymentsDb.contacts.contactQueries.getContact(contactId)?.let { + rowMap[contactId] = it + } + } + + } + + // Step 4 of 5: + // Fetch the corresponding `cloudkit_contacts_metadata.ckrecord_info` + + uniqueContactIds.forEach { contactId -> + queries.fetchMetadata( + id = contactId.toString() + ).executeAsOneOrNull()?.let { row -> + metadataMap[contactId] = row.record_blob + } + } + + } // + + FetchQueueBatchResult( + rowids = rowids, + rowidMap = rowidMap, + rowMap = rowMap, + metadataMap = metadataMap + ) + } + } + + suspend fun updateRows( + deleteFromQueue: List, + deleteFromMetadata: List, + updateMetadata: Map + ) { + withContext(Dispatchers.Default) { + db.transaction { + + deleteFromQueue.forEach { rowid -> + queries.deleteFromQueue(rowid) + } + + deleteFromMetadata.forEach { contactId -> + queries.deleteMetadata( + id = contactId.toString() + ) + } + + updateMetadata.forEach { (contactId, row) -> + val rowExists = queries.existsMetadata( + id = contactId.toString() + ).executeAsOne() > 0 + if (rowExists) { + queries.updateMetadata( + record_blob = row.recordBlob, + id = contactId.toString() + ) + } else { + queries.addMetadata( + id = contactId.toString(), + record_creation = row.recordCreation, + record_blob = row.recordBlob + ) + } + } + } + } + } + + suspend fun fetchOldestCreation(): Long? { + return withContext(Dispatchers.Default) { + val row = queries.fetchOldestCreation_Contacts().executeAsOneOrNull() + row?.record_creation + } + } + + suspend fun updateRows( + downloadedContacts: List, + updateMetadata: Map + ) { + // We are seeing crashes when accessing the values within the List. + // Perhaps because the List was created in Swift ? + // The workaround seems to be to copy the list here, + // or otherwise process it outside of the `withContext` below. + val contacts = downloadedContacts.map { it.copy() } + + withContext(Dispatchers.Default) { + val contactQueries = paymentsDb.contacts.contactQueries + + db.transaction { + for (contact in contacts) { + + val rowExists = queries.existsMetadata( + id = contact.id.toString() + ).executeAsOne() > 0 + if (!rowExists) { + contactQueries.saveContact(contact, notify = false) + } + } + + for ((contactId, row) in updateMetadata) { + val rowExists = queries.existsMetadata( + id = contactId.toString() + ).executeAsOne() > 0 + + if (rowExists) { + queries.updateMetadata( + record_blob = row.recordBlob, + id = contactId.toString() + ) + } else { + queries.addMetadata( + id = contactId.toString(), + record_creation = row.recordCreation, + record_blob = row.recordBlob + ) + } + } // + } + } + } + + suspend fun enqueueMissingItems() { + withContext(Dispatchers.Default) { + val rawContactQueries = paymentsDb.database.contactsQueries + + db.transaction { + + // Step 1 of 3: + // Fetch list of contact ID's that are already represented in the cloud. + + val cloudContactIds = mutableSetOf() + queries.scanMetadata().executeAsList().forEach { id -> + try { + val contactId = UUID.fromString(id) + cloudContactIds.add(contactId) + } catch (e: Exception) { + // UUID appears to be malformed within the database. + // Nothing we can do here - but let's at least not crash. + } + } + + // Step 2 of 3: + // Scan local contact ID's, looking to see if any are missing from the cloud. + + val missing = mutableListOf() + rawContactQueries.scanContacts().executeAsList().forEach { row -> + try { + if (!cloudContactIds.contains(row.id)) { + missing.add(MissingItem( + contactId = row.id, + timestamp = row.created_at + )) + } + } catch (e: Exception) { + // UUID appears to be malformed within the database. + // Nothing we can do here - but let's at least not crash. + } + } + + // Step 3 of 3: + // Add any missing items to the queue. + // + // But in what order do we want to upload them to the cloud ? + // + // We will choose to upload the OLDEST item first. + // This matches how they normally would have been uploaded. + // Also, when a user restores their wallet (e.g. on a new phone), + // we always want to download the newest contacts first. + // And this assumes the newest items in the cloud are the newest contacts. + // + // Since items are uploaded in FIFO order, + // we just need to make the oldest item have the + // smallest `date_added` value. + + missing.sortByDescending { it.timestamp } + + // The list is now sorted in descending order. + // Which means the newest item is at index 0, + // and the oldest item is at index . + + val now = currentTimestampMillis() + missing.forEachIndexed { idx, item -> + queries.addToQueue( + id = item.contactId.toString(), + date_added = now - idx + ) + } + } + } + } + + suspend fun clearDatabaseTables() { + withContext(Dispatchers.Default) { + db.transaction { + queries.deleteAllFromMetadata() + queries.deleteAllFromQueue() + } + } + } +} diff --git a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitDb.kt b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitDb.kt new file mode 100644 index 00000000..5b00bc04 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitDb.kt @@ -0,0 +1,13 @@ +package fr.acinq.phoenix.db + +import fr.acinq.phoenix.db.payments.* +import kotlinx.coroutines.* + +class CloudKitDb( + appDb: SqliteAppDb, + paymentsDb: SqlitePaymentsDb +): CloudKitInterface, CoroutineScope by MainScope() { + + val contacts = CloudKitContactsDb(paymentsDb) + val payments = CloudKitPaymentsDb(paymentsDb) +} diff --git a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitPaymentsDb.kt b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitPaymentsDb.kt new file mode 100644 index 00000000..bb5ca697 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitPaymentsDb.kt @@ -0,0 +1,375 @@ +package fr.acinq.phoenix.db + +import app.cash.sqldelight.Transacter +import app.cash.sqldelight.coroutines.asFlow +import fr.acinq.lightning.db.* +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.currentTimestampMillis +import fr.acinq.phoenix.data.WalletPaymentInfo +import fr.acinq.phoenix.data.WalletPaymentMetadata +import fr.acinq.phoenix.db.payments.* +import kotlin.collections.List +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +class CloudKitPaymentsDb( + private val paymentsDb: SqlitePaymentsDb +): CoroutineScope by MainScope() { + + private val db: Transacter = paymentsDb.database + private val queries = paymentsDb.database.cloudKitPaymentsQueries + + /** + * Provides a flow of the count of items within the cloudkit_payments_queue table. + */ + private val _queueCount = MutableStateFlow(0) + val queueCount: StateFlow = _queueCount.asStateFlow() + + data class MetadataRow( + val unpaddedSize: Long, + val recordCreation: Long, + val recordBlob: ByteArray + ) + + data class FetchQueueBatchResult( + + // The fetched rowid values from the `cloudkit_payments_queue` table + val rowids: List, + + // Maps `cloudkit_payments_queue.rowid` to the corresponding PaymentRowId. + // If missing from the map, then the `cloudkit_payments_queue` row was + // malformed or unrecognized. + val rowidMap: Map, + + // Maps to the fetch payment information in the database. + // If missing from the map, then the payment has been deleted from the database. + val rowMap: Map, + + // Maps to `cloudkit_payments_metadata.ckrecord_info`. + // If missing from the map, then then record doesn't exist in the database. + val metadataMap: Map + ) + + init { + // N.B.: There appears to be a subtle bug in SQLDelight's + // `.asFlow().mapToX()`, as described here: + // https://github.com/ACINQ/phoenix/pull/415 + launch { + queries.fetchQueueCount() + .asFlow() + .map { + withContext(Dispatchers.Default) { + db.transactionWithResult { + it.executeAsOne() + } + } + } + .collect { count -> + _queueCount.value = count + } + } + } + + suspend fun fetchQueueBatch(limit: Long): FetchQueueBatchResult { + return withContext(Dispatchers.Default) { + + val ckQueries = paymentsDb.database.cloudKitPaymentsQueries + + val rowids = mutableListOf() + val rowidMap = mutableMapOf() + val rowMap = mutableMapOf() + val metadataMap = mutableMapOf() + + db.transaction { + + // Step 1 of 4: + // Fetch the rows from the `cloudkit_payments_queue` batch. + // We are fetching the next/oldest X rows from the queue. + + val batch = ckQueries.fetchQueueBatch(limit).executeAsList() + + // Step 2 of 4: + // Process the batch, and fill out the `rowids` & `rowidMap` variable. + + batch.forEach { row -> + rowids.add(row.rowid) + rowidMap[row.rowid] = row.id + } // + + // Remember: there could be duplicates + val uniquePaymentIds = rowidMap.values.toSet() + + // Step 3 of 4: + // Fetch the corresponding payment info from the database. + // In order to optimize disk access, we fetch from 1 table at a time. + + val metadataPlaceholder = WalletPaymentMetadata() + + uniquePaymentIds.forEach { paymentId -> + paymentsDb._getPayment(paymentId)?.let { pair -> + rowMap[paymentId] = WalletPaymentInfo( + payment = pair.first, + metadata = metadataPlaceholder, + contact = null + ) + } + } + + uniquePaymentIds.forEach { paymentId -> + paymentsDb.metadataQueries.get(paymentId)?.let { metadata -> + rowMap[paymentId]?.let { + rowMap[paymentId] = it.copy( + metadata = metadata, + contact = null + ) + } + } + } // + + // Step 4 of 4: + // Fetch the corresponding `cloudkit_payments_metadata.ckrecord_info` + + uniquePaymentIds.forEach { paymentId -> + ckQueries.fetchMetadata(paymentId).executeAsOneOrNull()?.let { row -> + metadataMap[paymentId] = row.record_blob + } + } + + } // + + FetchQueueBatchResult( + rowids = rowids, + rowidMap = rowidMap, + rowMap = rowMap, + metadataMap = metadataMap + ) + } + } + + suspend fun updateRows( + deleteFromQueue: List, + deleteFromMetadata: List, + updateMetadata: Map + ) { + withContext(Dispatchers.Default) { + db.transaction { + + deleteFromQueue.forEach { rowid -> + queries.deleteFromQueue(rowid) + } + + deleteFromMetadata.forEach { paymentId -> + queries.deleteMetadata(paymentId) + } + + updateMetadata.forEach { (paymentId, row) -> + val rowExists = queries.existsMetadata(paymentId).executeAsOne() > 0 + if (rowExists) { + queries.updateMetadata( + unpadded_size = row.unpaddedSize, + record_blob = row.recordBlob, + id = paymentId + ) + } else { + queries.addMetadata( + id = paymentId, + unpadded_size = row.unpaddedSize, + record_creation = row.recordCreation, + record_blob = row.recordBlob + ) + } + } + } + } + } + + suspend fun fetchMetadata( + id: UUID + ): ByteArray? = withContext(Dispatchers.Default) { + + val row = queries.fetchMetadata(id = id).executeAsOneOrNull() + row?.record_blob + } + + suspend fun fetchOldestCreation(): Long? = withContext(Dispatchers.Default) { + + val row = queries.fetchOldestCreation().executeAsOneOrNull() + row?.record_creation + } + + suspend fun updateRows( + downloadedPayments: List, + downloadedPaymentsMetadata: Map, + updateMetadata: Map + ) { + withContext(Dispatchers.Default) { + + val inQueries = paymentsDb.database.paymentsIncomingQueries + val outQueries = paymentsDb.database.paymentsOutgoingQueries + val ckQueries = paymentsDb.database.cloudKitPaymentsQueries + val metaQueries = paymentsDb.database.paymentsMetadataQueries + + val incomingPaymentsDb = SqliteIncomingPaymentsDb(paymentsDb.database, paymentsDb.paymentMetadataQueue) + val outgoingPaymentsDb = SqliteOutgoingPaymentsDb(paymentsDb.database, paymentsDb.paymentMetadataQueue) + + db.transaction { + + downloadedPayments.forEach { payment -> + + val paymentId: UUID = payment.id + if (payment is IncomingPayment) { + val existing = inQueries.get(paymentId).executeAsOneOrNull() + if (existing == null) { + incomingPaymentsDb._addIncomingPayment(payment, metadata = null, notify = false) + } + } else if (payment is OutgoingPayment) { + val existing = outQueries.get(paymentId).executeAsOneOrNull() + if (existing == null) { + outgoingPaymentsDb._addOutgoingPayment(payment, metadata = null, notify = false) + } + } + } + + downloadedPaymentsMetadata.forEach { (paymentId, row) -> + val rowExists = metaQueries.hasMetadata(paymentId).executeAsOne() > 0 + if (!rowExists) { + metaQueries.addMetadata( + payment_id = paymentId, + lnurl_base_type = row.lnurl_base?.first, + lnurl_base_blob = row.lnurl_base?.second, + lnurl_metadata_type = row.lnurl_metadata?.first, + lnurl_metadata_blob = row.lnurl_metadata?.second, + lnurl_successAction_type = row.lnurl_successAction?.first, + lnurl_successAction_blob = row.lnurl_successAction?.second, + lnurl_description = row.lnurl_description, + user_description = row.user_description, + user_notes = row.user_notes, + modified_at = row.modified_at, + original_fiat_type = row.original_fiat?.first, + original_fiat_rate = row.original_fiat?.second, + lightning_address = row.lightning_address + ) + } + } // + + updateMetadata.forEach { (paymentId, row) -> + val rowExists = ckQueries.existsMetadata(paymentId).executeAsOne() > 0 + if (rowExists) { + ckQueries.updateMetadata( + unpadded_size = row.unpaddedSize, + record_blob = row.recordBlob, + id = paymentId + ) + } else { + ckQueries.addMetadata( + id = paymentId, + unpadded_size = row.unpaddedSize, + record_creation = row.recordCreation, + record_blob = row.recordBlob + ) + } + } // + } + } + } + + suspend fun enqueueOutdatedItems() = withContext(Dispatchers.Default) { + + val ckQueries = paymentsDb.database.cloudKitPaymentsQueries + db.transaction { + + val paymentIds = mutableListOf() + ckQueries.listNonZeroSizes().executeAsList().forEach { row -> + paymentIds.add(row.id) + } + + for (paymentId in paymentIds) { + ckQueries.addToQueue( + id = paymentId, + date_added = currentTimestampMillis() + ) + } + } + } + + data class MissingItem( + val paymentId: UUID, + val timestamp: Long + ) + + suspend fun enqueueMissingItems() { + withContext(Dispatchers.Default) { + + val ckQueries = paymentsDb.database.cloudKitPaymentsQueries + db.transaction { + + // Step 1 of 3: + // Fetch list of payment ID's that are already represented in the cloud. + + val existing = mutableSetOf() + ckQueries.scanMetadata().executeAsList().forEach { uuid -> + existing.add(uuid) + } + + // Step 2 of 3: + // Scan local payment ID's, looking to see if any are missing from the cloud. + + val missing = mutableListOf() + run { + val inQueries = paymentsDb.database.paymentsIncomingQueries + inQueries.listSuccessfulIds().executeAsList().forEach { row -> + if (!existing.contains(row.id)) { + missing.add(MissingItem(row.id, row.received_at)) + } + } + } + run { + val outQueries = paymentsDb.database.paymentsOutgoingQueries + outQueries.listSuccessfulIds().executeAsList().forEach { row -> + if (!existing.contains(row.id)) { + missing.add(MissingItem(row.id, row.completed_at)) + } + } + } + + // Step 3 of 3: + // Add any missing items to the queue. + // + // But in what order do we want to upload them to the cloud ? + // + // We will choose to upload the OLDEST item first. + // This matches how they normally would have been uploaded. + // Also, when a user restores their wallet (e.g. on a new phone), + // we always want to download the newest payments first. + // And this assumes the newest items in the cloud are the newest payments. + // + // Since items are uploaded in FIFO order, + // we just need to make the oldest item have the + // smallest `date_added` value. + + missing.sortByDescending { it.timestamp } + + // The list is now sorted in descending order. + // Which means the newest item is at index 0, + // and the oldest item is at index . + + val now = currentTimestampMillis() + missing.forEachIndexed { idx, item -> + ckQueries.addToQueue( + id = item.paymentId, + date_added = now - idx + ) + } + } + } + } + + suspend fun clearDatabaseTables() { + withContext(Dispatchers.Default) { + db.transaction { + queries.deleteAllFromMetadata() + queries.deleteAllFromQueue() + } + } + } +} diff --git a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/DbFactory.ios.kt b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/DbFactory.ios.kt new file mode 100644 index 00000000..8851c1fe --- /dev/null +++ b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/DbFactory.ios.kt @@ -0,0 +1,89 @@ +package fr.acinq.phoenix.db + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.native.NativeSqliteDriver +import app.cash.sqldelight.driver.native.wrapConnection +import co.touchlab.sqliter.DatabaseConfiguration +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 +import fr.acinq.phoenix.utils.getDatabaseFilesDirectoryPath + +actual fun createChannelsDbDriver( + ctx: PlatformContext, + fileName: String +): SqlDriver { + val schema = ChannelsDatabase.Schema + + // The foreign_keys constraint needs to be set via the DatabaseConfiguration: + // https://github.com/cashapp/sqldelight/issues/1356 + + val dbDir = getDatabaseFilesDirectoryPath(ctx) + val configuration = DatabaseConfiguration( + name = fileName, + version = schema.version.toInt(), + extendedConfig = DatabaseConfiguration.Extended( + basePath = dbDir, + foreignKeyConstraints = true + ), + create = { connection -> + wrapConnection(connection) { schema.create(it) } + }, + upgrade = { connection, oldVersion, newVersion -> + wrapConnection(connection) { schema.migrate(it, oldVersion.toLong(), newVersion.toLong()) } + } + ) + return NativeSqliteDriver(configuration) +} + +actual fun createPaymentsDbDriver( + ctx: PlatformContext, + fileName: String, + onError: (String) -> Unit +): SqlDriver { + val schema = PaymentsDatabase.Schema + + val dbDir = getDatabaseFilesDirectoryPath(ctx) + val configuration = DatabaseConfiguration( + name = fileName, + version = schema.version.toInt(), + extendedConfig = DatabaseConfiguration.Extended( + basePath = dbDir, + foreignKeyConstraints = true + ), + create = { connection -> + wrapConnection(connection) { schema.create(it) } + }, + upgrade = { connection, oldVersion, newVersion -> + wrapConnection(connection) { schema.migrate(it, oldVersion.toLong(), newVersion.toLong(), AfterVersion10(onError), AfterVersion11(onError)) } + } + ) + return NativeSqliteDriver(configuration) +} + +actual fun createAppDbDriver( + ctx: PlatformContext +): SqlDriver { + val schema = AppDatabase.Schema + val name = "app.sqlite" + + val dbDir = getDatabaseFilesDirectoryPath(ctx) + val configuration = DatabaseConfiguration( + name = name, + version = schema.version.toInt(), + extendedConfig = DatabaseConfiguration.Extended( + basePath = dbDir, + foreignKeyConstraints = true + ), + create = { connection -> + wrapConnection(connection) { schema.create(it) } + }, + upgrade = { connection, oldVersion, newVersion -> + wrapConnection(connection) { schema.migrate(it, oldVersion.toLong(), newVersion.toLong()) } + } + ) + return NativeSqliteDriver(configuration) +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/DbHooks.ios.kt b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/DbHooks.ios.kt new file mode 100644 index 00000000..c40f5a15 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/DbHooks.ios.kt @@ -0,0 +1,36 @@ +package fr.acinq.phoenix.db + +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.currentTimestampMillis +import fr.acinq.phoenix.db.payments.CloudKitInterface +import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase + +actual fun didSaveWalletPayment(id: UUID, database: PaymentsDatabase) { + database.cloudKitPaymentsQueries.addToQueue(id = id, date_added = currentTimestampMillis()) +} + +actual fun didDeleteWalletPayment(id: UUID, database: PaymentsDatabase) { + database.cloudKitPaymentsQueries.addToQueue(id = id, date_added = currentTimestampMillis()) +} + +actual fun didUpdateWalletPaymentMetadata(id: UUID, database: PaymentsDatabase) { + database.cloudKitPaymentsQueries.addToQueue(id = id, date_added = currentTimestampMillis()) +} + +actual fun didSaveContact(contactId: UUID, database: PaymentsDatabase) { + database.cloudKitContactsQueries.addToQueue( + id = contactId.toString(), + date_added = currentTimestampMillis() + ) +} + +actual fun didDeleteContact(contactId: UUID, database: PaymentsDatabase) { + database.cloudKitContactsQueries.addToQueue( + id = contactId.toString(), + date_added = currentTimestampMillis() + ) +} + +actual fun makeCloudKitDb(appDb: SqliteAppDb, paymentsDb: SqlitePaymentsDb): CloudKitInterface? { + return CloudKitDb(appDb, paymentsDb) +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/ios/BusinessManager.kt b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/ios/BusinessManager.kt new file mode 100644 index 00000000..a9112af9 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/ios/BusinessManager.kt @@ -0,0 +1,314 @@ +package fr.acinq.phoenix.ios + +import co.touchlab.kermit.Logger +import com.machankura.compose.AppVersion +import com.machankura.compose.ui.composable.widgets.wallet.WalletAvatars +import fr.acinq.lightning.LiquidityEvents +import fr.acinq.lightning.PaymentEvents +import fr.acinq.lightning.logging.LoggerFactory +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.PhoenixGlobal +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.DateUtils +import fr.acinq.phoenix.utils.MnemonicLanguage +import fr.acinq.phoenix.utils.PlatformContext +import fr.acinq.phoenix.utils.preferences.GlobalPrefs +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 platform.Foundation.NSBundle +import kotlin.time.Duration.Companion.hours + +object BusinessManager { + private val log = Logger.withTag("BusinessManager") + private val supervisor = SupervisorJob() + private val scope = CoroutineScope(Dispatchers.Default + supervisor) + + private val startupMutex = Mutex() + + + val phoenixGlobal: PhoenixGlobal = PhoenixGlobal( + ctx = PlatformContext() + ) + + + + /** A map of (walletId -> active businesses) */ + private val _businessFlow = MutableStateFlow>(emptyMap()) + val businessFlow = _businessFlow.asStateFlow() + + /** Map of jobs monitoring events/payments once business starts */ + private val eventsMonitoringJobs = mutableMapOf() //List>() + + suspend fun startNewBusiness(words: List, isHeadless: Boolean): StartBusinessResult = startupMutex.withLock { + val business = PhoenixBusiness(phoenixGlobal) + + val walletInfo = try { + val seed = business.walletManager.mnemonicsToSeed(words, wordList = MnemonicLanguage.English.wordlist()) + business.walletManager.loadWallet(seed) + } catch (e: Exception) { + return StartBusinessResult.Failure.LoadWalletError + } + + val walletId = WalletId(walletInfo.nodeIdHash) + val nodeId = walletInfo.nodeId.toHex() + + val dataStoreManager = business.dataStoreManager + + val globalPrefs: GlobalPrefs = dataStoreManager.loadGlobalPrefsForWallet() + + 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 userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId) + val internalPrefs = dataStoreManager.loadInternalPrefsForWallet(walletId) + + val businessInFlow = businessFlow.value[walletId]?.business + if (businessInFlow != null) { + log.i { "business already exists in flow, ignoring..." } + return StartBusinessResult.Success(walletInfo, businessInFlow) + } + + + return try { + log.i("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, phoenixGlobal.currencyManager, userPrefs) } + } else null, + monitorNodeEventsJob = scope.launch { monitorNodeEvents(walletId, business.peerManager, business.nodeParamsManager, globalPrefs, internalPrefs) }, + monitorFcmTokenJob = scope.launch { monitorFcmToken(globalPrefs, 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.i("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.i("business initialisation has successfully completed") + StartBusinessResult.Success(walletInfo, business) + } catch (e: Exception) { + log.e("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.i("stopping all headless businesses (${headlessBusinesses.size})...") + headlessBusinesses.forEach { doStopBusiness(it.key, it.value) } + _businessFlow.value = businessFlow.value.minus(headlessBusinesses.keys) + } + + fun stopAllBusinesses() { + log.i("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() + } + phoenixGlobal.currencyManager.stopMonitoringForWallet(walletId.nodeIdHash) + } + + fun refreshFcmToken() { +// TODO: FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task -> +// if (!task.isSuccessful) { +// fr.acinq.phoenix.android.BusinessManager.log.warn("fetching FCM registration token failed: ${task.exception?.localizedMessage}") +// return@OnCompleteListener +// } +// task.result?.let { fr.acinq.phoenix.android.BusinessManager.scope.launch { application.globalPrefs.saveFcmToken(it) } } +// }) + } + + private suspend fun monitorFcmToken(globalPrefs: GlobalPrefs,business: PhoenixBusiness) { + val token = globalPrefs.getFcmToken.filterNotNull().first() + business.connectionsManager.connections.first { it.peer == Connection.ESTABLISHED } + delay(5000) + log.i("registering fcm token=$token") + business.registerFcmToken(token) + } + + private suspend fun monitorNodeEvents(walletId: WalletId, peerManager: PeerManager, nodeParamsManager: NodeParamsManager, globalPrefs: GlobalPrefs, 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.d("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.d("ignore this liquidity event as a similar notification was recently displayed") + return@collect + } else { + internalPrefs.saveLastRejectedOnchainSwap(event) + } + } + val walletMetadata = globalPrefs.getAvailableWalletsMeta.first().getByWalletIdOrDefault(walletId) + when (val reason = event.reason) { + is LiquidityEvents.Rejected.Reason.PolicySetToDisabled -> { + // TODO: SystemNotificationHelper.notifyPaymentRejectedPolicyDisabled(appContext, walletId, walletMetadata, event.source, event.amount, nextTimeout?.second) + } + is LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee -> { + // TODO: SSystemNotificationHelper.notifyPaymentRejectedOverAbsolute(appContext, walletId, walletMetadata, event.source, event.amount, event.fee, reason.maxAbsoluteFee, nextTimeout?.second) + } + is LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee -> { + // TODO: SSystemNotificationHelper.notifyPaymentRejectedOverRelative(appContext, walletId, walletMetadata, event.source, event.amount, event.fee, reason.maxRelativeFeeBasisPoints, nextTimeout?.second) + } + is LiquidityEvents.Rejected.Reason.MissingOffChainAmountTooLow -> { + // TODO: SSystemNotificationHelper.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 -> { + // TODO: SSystemNotificationHelper.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 -> { +// TODO: 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) + // TODO: InflightPaymentsWatcher +// if (inFlightPaymentsCount == 0) { +// InflightPaymentsWatcher.cancel(appContext) +// } else { +// InflightPaymentsWatcher.scheduleOnce(appContext, delay = 2.hours) +// } + } + } + + fun clear() { + supervisor.cancel() + } +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/managers/DataStoreManager.ios.kt b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/managers/DataStoreManager.ios.kt new file mode 100644 index 00000000..6370f333 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/managers/DataStoreManager.ios.kt @@ -0,0 +1,27 @@ +package fr.acinq.phoenix.managers + +import fr.acinq.phoenix.utils.PlatformContext +import kotlinx.cinterop.ExperimentalForeignApi +import okio.Path +import okio.Path.Companion.toPath +import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSURL +import platform.Foundation.NSUserDomainMask + +@OptIn(ExperimentalForeignApi::class) +actual fun computePreferencePath( + platformContext: PlatformContext, + dataStoreFileName: String +): Path { + val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory( + directory = NSDocumentDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = false, + error = null, + ) + val path = requireNotNull(documentDirectory).path + "${Path.DIRECTORY_SEPARATOR}$dataStoreFileName" + + return path.toPath() +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/managers/global/NetworkMonitor.ios.kt b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/managers/global/NetworkMonitor.ios.kt new file mode 100644 index 00000000..14da60ef --- /dev/null +++ b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/managers/global/NetworkMonitor.ios.kt @@ -0,0 +1,93 @@ +package fr.acinq.phoenix.managers.global + +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.logging.debug +import fr.acinq.phoenix.utils.PlatformContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import platform.Network.nw_path_get_status +import platform.Network.nw_path_monitor_cancel +import platform.Network.nw_path_monitor_create +import platform.Network.nw_path_monitor_set_queue +import platform.Network.nw_path_monitor_set_update_handler +import platform.Network.nw_path_monitor_start +import platform.Network.nw_path_monitor_t +import platform.Network.nw_path_status_invalid +import platform.Network.nw_path_status_satisfiable +import platform.Network.nw_path_status_satisfied +import platform.Network.nw_path_status_unsatisfied +import platform.darwin.dispatch_get_main_queue + +actual class NetworkMonitor actual constructor( + loggerFactory: LoggerFactory, + ctx: PlatformContext +) : CoroutineScope by MainScope() { + + private val logger = loggerFactory.newLogger(this::class) + + private val _networkState = MutableStateFlow(NetworkState.NotAvailable) + actual val networkState: StateFlow = _networkState + + private var enabled = true + private var monitor: nw_path_monitor_t = null + + actual fun enable() { + enabled = true + start() + } + + actual fun disable() { + enabled = false + stop() + _networkState.value = NetworkState.Available + } + + @OptIn(ExperimentalUnsignedTypes::class) + actual fun start() { + if (!enabled || monitor != null) { + return + } + + monitor = nw_path_monitor_create() + nw_path_monitor_set_update_handler(monitor) { path -> + val status = when (nw_path_get_status(path)) { + nw_path_status_satisfied -> { + logger.debug { "status = nw_path_status_satisfied" } + NetworkState.Available + } + nw_path_status_satisfiable -> { + logger.debug { "status = nw_path_status_satisfiable" } + NetworkState.Available + } + nw_path_status_unsatisfied -> { + logger.debug { "status = nw_path_status_unsatisfied" } + NetworkState.NotAvailable + } + nw_path_status_invalid -> { + logger.debug { "status = nw_path_status_invalid" } + NetworkState.NotAvailable + } + else -> { + logger.debug { "status = nw_path_status_unknown" } + NetworkState.NotAvailable + } + } + + launch { _networkState.value = status } + } + + nw_path_monitor_set_queue(monitor, dispatch_get_main_queue()) + nw_path_monitor_start(monitor) + } + + actual fun stop() { + if (monitor != null) { + // NB: once cancelled, a monitor instance cannot be started again + nw_path_monitor_cancel(monitor) + monitor = null + } + } +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/security/CCCipher.kt b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/security/CCCipher.kt new file mode 100644 index 00000000..2567867f --- /dev/null +++ b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/security/CCCipher.kt @@ -0,0 +1,177 @@ +package fr.acinq.phoenix.security + +import kotlinx.cinterop.* +import platform.CoreCrypto.* +import platform.posix.* + +@OptIn(ExperimentalForeignApi::class) +private val almostEmptyArrayPinned = ByteArray(1).pin() + +@ExperimentalForeignApi +public fun ByteArray.safeRefTo(index: Int): CValuesRef { + if (index == size) return almostEmptyArrayPinned.addressOf(0) + return refTo(index) +} + +/** + * Might just load up cryptography-kotlin as a dependency as it would allow us to have this logic in commonMain + * https://github.com/whyoleg/cryptography-kotlin.git + */ +@OptIn(ExperimentalForeignApi::class) +class CCCipher( + private val algorithm: CCAlgorithm, + private val mode: CCMode, + private val padding: CCPadding, + private val key: ByteArray, +) { + companion object { + fun phoenixCipherForKey(key: ByteArray): CCCipher { + return CCCipher( + algorithm = kCCAlgorithmAES, + mode = kCCModeCBC, + padding = ccPKCS7Padding, + key = key, + ) + } + } + fun encrypt(iv: ByteArray?, plaintext: ByteArray): ByteArray = memScoped { + useCryptor { cryptorRef -> + cryptorRef.create(kCCEncrypt, iv?.refTo(0)) + val ciphertextOutput = ByteArray(cryptorRef.outputLength(plaintext.size)) + + val dataOutMoved = alloc() + val moved = cryptorRef.update( + dataIn = plaintext.safeRefTo(0), + dataInLength = plaintext.size, + dataOut = ciphertextOutput.safeRefTo(0), + dataOutAvailable = ciphertextOutput.size, + dataOutMoved = dataOutMoved, + ) + + if (ciphertextOutput.size != moved) cryptorRef.final( + dataOut = ciphertextOutput.refTo(moved), + dataOutAvailable = ciphertextOutput.size - moved, + dataOutMoved = dataOutMoved, + ) + ciphertextOutput + } + } + + fun decrypt(iv: ByteArray?, ciphertext: ByteArray, ciphertextStartIndex: Int): ByteArray = memScoped { + useCryptor { cryptorRef -> + cryptorRef.create(kCCDecrypt, iv?.refTo(0)) + + val plaintextOutput = ByteArray(cryptorRef.outputLength(ciphertext.size - ciphertextStartIndex)) + + val dataOutMoved = alloc() + var moved = cryptorRef.update( + dataIn = ciphertext.safeRefTo(ciphertextStartIndex), + dataInLength = ciphertext.size - ciphertextStartIndex, + dataOut = plaintextOutput.safeRefTo(0), + dataOutAvailable = plaintextOutput.size, + dataOutMoved = dataOutMoved + ) + + if (plaintextOutput.size != moved) moved += cryptorRef.final( + dataOut = plaintextOutput.refTo(moved), + dataOutAvailable = plaintextOutput.size - moved, + dataOutMoved = dataOutMoved + ) + + if (plaintextOutput.size == moved) { + plaintextOutput + } else { + plaintextOutput.copyOf(moved) + } + } + } + + private inline fun MemScope.useCryptor(block: (cryptorRef: CCCryptorRefVar) -> T): T { + val cryptorRef = alloc() + try { + return block(cryptorRef) + } finally { + CCCryptorRelease(cryptorRef.value) + } + } + + private fun CCCryptorRefVar.create(op: CCOperation, iv: CValuesRef<*>?) { + checkResult( + CCCryptorCreateWithMode( + op = op, + cryptorRef = ptr, + alg = algorithm, + mode = mode, + padding = padding, + key = key.refTo(0), + keyLength = key.size.convert(), + iv = iv, + + // unused options + options = 0.convert(), + tweak = null, + tweakLength = 0.convert(), + numRounds = 0, + ) + ) + } + + private fun CCCryptorRefVar.outputLength(inputLength: Int): Int { + return CCCryptorGetOutputLength( + cryptorRef = value, + inputLength = inputLength.convert(), + final = true + ).convert() + } + + private fun CCCryptorRefVar.update( + dataIn: CValuesRef<*>, + dataInLength: Int, + dataOut: CValuesRef<*>, + dataOutAvailable: Int, + dataOutMoved: size_tVar, + ): Int { + checkResult( + CCCryptorUpdate( + cryptorRef = value, + dataIn = dataIn, + dataInLength = dataInLength.convert(), + dataOut = dataOut, + dataOutAvailable = dataOutAvailable.convert(), + dataOutMoved = dataOutMoved.ptr + ) + ) + return dataOutMoved.value.convert() + } + + private fun CCCryptorRefVar.final( + dataOut: CValuesRef<*>, + dataOutAvailable: Int, + dataOutMoved: size_tVar, + ): Int { + checkResult( + CCCryptorFinal( + cryptorRef = value, + dataOut = dataOut, + dataOutAvailable = dataOutAvailable.convert(), + dataOutMoved = dataOutMoved.ptr + ) + ) + return dataOutMoved.value.convert() + } +} + +private fun checkResult(result: CCCryptorStatus) { + error( + when (result) { + kCCSuccess -> return + kCCParamError -> "Illegal parameter value." + kCCBufferTooSmall -> "Insufficient buffer provided for specified operation." + kCCMemoryFailure -> "Memory allocation failure." + kCCAlignmentError -> "Input size was not aligned properly." + kCCDecodeError -> "Input data did not decode or decrypt properly." + kCCUnimplemented -> "Function not implemented for the current algorithm." + else -> "CCCrypt failed with code $result" + } + ) +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/security/KeyChainHelper.kt b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/security/KeyChainHelper.kt new file mode 100644 index 00000000..46073031 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/security/KeyChainHelper.kt @@ -0,0 +1,264 @@ +package fr.acinq.phoenix.security + +import co.touchlab.kermit.Logger +import com.machankura.compose.AppVersion +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.Lightning +import kotlinx.cinterop.ExperimentalForeignApi + +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.alloc +import kotlinx.cinterop.allocArrayOf +import kotlinx.cinterop.convert +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.usePinned +import kotlinx.cinterop.value +import platform.CoreCrypto.ccPKCS7Padding +import platform.CoreCrypto.kCCAlgorithmAES +import platform.CoreCrypto.kCCModeCBC +import platform.CoreFoundation.CFAutorelease +import platform.CoreFoundation.CFDictionaryAddValue +import platform.CoreFoundation.CFDictionaryCreateMutable +import platform.CoreFoundation.CFDictionaryRef +import platform.CoreFoundation.CFStringRef +import platform.CoreFoundation.CFTypeRef +import platform.CoreFoundation.CFTypeRefVar +import platform.CoreFoundation.kCFBooleanFalse +import platform.CoreFoundation.kCFBooleanTrue +import platform.Foundation.CFBridgingRelease +import platform.Foundation.CFBridgingRetain +import platform.Foundation.NSData +import platform.Foundation.NSKeyedArchiver +import platform.Foundation.NSKeyedUnarchiver +import platform.Foundation.NSNumber +import platform.Foundation.NSString +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.create +import platform.Foundation.dataUsingEncoding +import platform.Security.SecItemAdd +import platform.Security.SecItemCopyMatching +import platform.Security.SecItemUpdate +import platform.Security.kSecAttrAccessGroup +import platform.Security.kSecAttrAccessible +import platform.Security.kSecAttrAccessibleAfterFirstUnlock +import platform.Security.kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly +import platform.Security.kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly +import platform.Security.kSecAttrAccessibleWhenUnlocked +import platform.Security.kSecAttrAccessibleWhenUnlockedThisDeviceOnly +import platform.Security.kSecAttrAccount +import platform.Security.kSecAttrService +import platform.Security.kSecClass +import platform.Security.kSecClassGenericPassword +import platform.Security.kSecMatchLimit +import platform.Security.kSecMatchLimitOne +import platform.Security.kSecReturnData +import platform.Security.kSecValueData +import platform.darwin.OSStatus +import platform.darwin.noErr +import platform.posix.memcpy + +/*** + * If the KVault repo was being maintained we would've probably just used that as a dependency + * + * https://github.com/Liftric/KVault + */ +@OptIn(ExperimentalForeignApi::class) +object KeyChainHelper { + private val accessibility: Accessible = Accessible.AfterFirstUnlock + /** + * kSecAttrAccessible attributes wrapper. + * attribute enables you to control item availability relative to the lock state of the device. + * It also lets you specify eligibility for restoration to a new device. + * If the attribute ends with the string ThisDeviceOnly, the item can be restored to the same device + * that created a backup, but it isn’t migrated when restoring another device’s backup data. + */ + enum class Accessible(val value: CFStringRef?) { + WhenPasscodeSetThisDeviceOnly(kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly), + WhenUnlockedThisDeviceOnly(kSecAttrAccessibleWhenUnlockedThisDeviceOnly), + WhenUnlocked(kSecAttrAccessibleWhenUnlocked), + AfterFirstUnlock(kSecAttrAccessibleAfterFirstUnlock), + AfterFirstUnlockThisDeviceOnly(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) + } + + private val log = Logger.withTag("KeyChainHelper") + + + /** + * Returns the data value of an object in the store. + * @param forKey The key to query + * @return The stored bytes value + */ + fun data(forKey: String): ByteArray? { + log.i("Getting byteArray for $forKey") + return value(forKey)?.toByteArray() + } + + private fun value(forKey: String): NSData? = context(forKey) { (account) -> + log.i("value for $forKey") + val query = query( + kSecClass to kSecClassGenericPassword, + kSecAttrAccount to account, + kSecReturnData to kCFBooleanTrue, + kSecMatchLimit to kSecMatchLimitOne, + ) + log.i("query: $query") + + memScoped { + val result = alloc() + SecItemCopyMatching(query, result.ptr) + CFBridgingRelease(result.value) as? NSData + } + } + + fun set(key: String, dataValue: ByteArray): Boolean { + log.i("set for $key") + return addOrUpdate(key, dataValue.toNSData()) + } + + private fun addOrUpdate(key: String, value: NSData?): Boolean { + log.i("addOrUpdate for $key") + return if (existsObject(key)) { + update(key, value) + } else { + add(key, value) + } + } + + /** + * Checks if object with the given key exists in the Keychain. + * @param forKey The key to query + * @return True or false, depending on whether it is in the Keychain or not + */ + fun existsObject(forKey: String): Boolean = context(forKey) { (account) -> + log.i("existsObject for $forKey") + val query = query( + kSecClass to kSecClassGenericPassword, + kSecAttrAccount to account, + kSecReturnData to kCFBooleanFalse, + ) + log.i("query: $query") + + SecItemCopyMatching(query, null) + .validate() + } + + + private fun add(key: String, value: NSData?): Boolean = context(key, value) { (account, data) -> + log.i("add value for $key") + val query = query( + kSecClass to kSecClassGenericPassword, + kSecAttrAccount to account, + kSecValueData to data, + kSecAttrAccessible to accessibility.value + ) + log.i("query: $query") + SecItemAdd(query, null) + .validate() + + } + + private fun update(key: String, value: Any?): Boolean = context(key, value) { (account, data) -> + log.i("update for $key") + val query = query( + kSecClass to kSecClassGenericPassword, + kSecAttrAccount to account, + kSecReturnData to kCFBooleanFalse, + ) + + val updateQuery = query( + kSecValueData to data + ) + + SecItemUpdate(query, updateQuery) + .validate() + } + + private class Context(val refs: Map) { + fun query(vararg pairs: Pair): CFDictionaryRef? { + val map = mapOf(*pairs).plus(refs.filter { it.value != null }) + return CFDictionaryCreateMutable( + null, map.size.convert(), null, null + ).apply { + map.entries.forEach { CFDictionaryAddValue(this, it.key, it.value) } + }.apply { + CFAutorelease(this) + } + } + } + + private fun context(vararg values: Any?, block: Context.(List) -> T): T { + val standard = mapOf( + kSecAttrService to CFBridgingRetain(AppVersion.serviceName), + kSecAttrAccessGroup to CFBridgingRetain(null) + ) + val custom = arrayOf(*values).map { CFBridgingRetain(it) } + return block.invoke(Context(standard), custom).apply { + standard.values.plus(custom).forEach { CFBridgingRelease(it) } + } + } + + private fun String.toNSData(): NSData? = + NSString.create(string = this).dataUsingEncoding(NSUTF8StringEncoding) + + private fun NSNumber.toNSData() = NSKeyedArchiver.archivedDataWithRootObject(this) + private fun NSData.toNSNumber() = NSKeyedUnarchiver.unarchiveObjectWithData(this) as? NSNumber + + private val NSData.stringValue: String? + get() = NSString.create(this, NSUTF8StringEncoding) as String? + + private fun NSData.toByteArray(): ByteArray = + ByteArray(length.toInt()).apply { + if (isNotEmpty()) { + usePinned { + memcpy(it.addressOf(0), this@toByteArray.bytes, this@toByteArray.length) + } + } + } + + private fun ByteArray.toNSData(): NSData = + memScoped { + NSData.create(bytes = allocArrayOf(this@toNSData), length = this@toNSData.size.convert()) + } + + private fun OSStatus.validate(): Boolean { + log.i("validate: ${toUInt()}") + return toUInt() == noErr + } + + private fun getOrCreateKeyNoAuthRequired(): ByteArray { + log.i("getOrCreateKeyNoAuthRequired") + data(KeyStoreNames.KEY_NO_AUTH)?.let { + log.i("Key (${KeyStoreNames.KEY_NO_AUTH}) found") + return it + } + log.i("Key (${KeyStoreNames.KEY_NO_AUTH}) not found need to create new key") + // TODO: Generate secretKey using keychain + val secretKey = Lightning.randomKey().value.toByteArray() + + if (set(KeyStoreNames.KEY_NO_AUTH, secretKey)) { + log.i("Successfully set key") + } else { + log.i("failed to save key") + } + return secretKey + } + + private fun getKeyForName(keyName: String): ByteArray = when (keyName) { + KeyStoreNames.KEY_NO_AUTH -> getOrCreateKeyNoAuthRequired() + KeyStoreNames.KEY_FOR_PINCODE_V1 -> getOrCreateKeyNoAuthRequired() + else -> throw IllegalArgumentException("unhandled key=$keyName") + } + + internal fun getEncryptionCipher(keyName: String): CCCipher { + val key = getKeyForName(keyName) + + return CCCipher.phoenixCipherForKey(key) + } + + internal fun getDecryptionCipher(keyName: String): CCCipher { + val key = getKeyForName(keyName) + + return CCCipher.phoenixCipherForKey(key) + } +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/security/KeyStoreFunctions.ios.kt b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/security/KeyStoreFunctions.ios.kt new file mode 100644 index 00000000..d579f0a2 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/security/KeyStoreFunctions.ios.kt @@ -0,0 +1,26 @@ +package fr.acinq.phoenix.security + +import fr.acinq.lightning.Lightning +import platform.CoreCrypto.CCPseudoRandomAlgorithmVar + +actual fun keyStoreDecryption( + keyName: String, + iv: ByteArray, + ciphertext: ByteArray +): ByteArray = KeyChainHelper.getDecryptionCipher( + keyName = keyName, +).decrypt( + iv, + ciphertext, + 0 +) + +actual fun keyStoreEncryption(keyName: String, plainText: ByteArray): Pair { + val iv = Lightning.randomBytes(16) + val cipherText = KeyChainHelper.getEncryptionCipher(keyName).encrypt( + iv, + plainText + ) + + return Pair(iv, cipherText) +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/utils/PlatformContext.ios.kt b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/utils/PlatformContext.ios.kt new file mode 100644 index 00000000..7e0e1e4d --- /dev/null +++ b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/utils/PlatformContext.ios.kt @@ -0,0 +1,31 @@ +package fr.acinq.phoenix.utils + +import co.touchlab.kermit.Severity +import platform.Foundation.NSCachesDirectory +import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSSearchPathForDirectoriesInDomains +import platform.Foundation.NSTemporaryDirectory +import platform.Foundation.NSUserDomainMask + +actual class PlatformContext( + val logger: ((Severity, String, String) -> Unit)? = null +) + +actual fun getApplicationFilesDirectoryPath(ctx: PlatformContext): String = + NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true)[0] as String + +actual fun getApplicationCacheDirectoryPath(ctx: PlatformContext): String = + NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, true)[0] as String + +actual fun getDatabaseFilesDirectoryPath(ctx: PlatformContext): String? { + return NSFileManager.defaultManager.containerURLForSecurityApplicationGroupIdentifier( + groupIdentifier = "group.co.acinq.phoenix" + )?.URLByAppendingPathComponent( + pathComponent = "databases", + isDirectory = true + )?.path +} + +actual fun getTemporaryDirectoryPath(ctx: PlatformContext): String = + NSTemporaryDirectory() diff --git a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.ios.kt b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.ios.kt new file mode 100644 index 00000000..cdec6348 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.ios.kt @@ -0,0 +1,15 @@ +package fr.acinq.phoenix.utils.extensions + +import fr.acinq.phoenix.data.DecryptSeedResult + +actual inline fun gracefulSingleSeedDecryption(action: () -> DecryptSeedResult): DecryptSeedResult = try { + action.invoke() +} catch (e: Throwable) { + return DecryptSeedResult.Failure.DecryptionError(e) +} + +actual inline fun gracefulMultiSeedDecryption(action: () -> DecryptSeedResult): DecryptSeedResult = try { + action.invoke() +} catch (e: Exception) { + return DecryptSeedResult.Failure.DecryptionError(e) +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/utils/logger/LoggerConfig.ios.kt b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/utils/logger/LoggerConfig.ios.kt new file mode 100644 index 00000000..54ff7126 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/utils/logger/LoggerConfig.ios.kt @@ -0,0 +1,15 @@ +package fr.acinq.phoenix.utils.logger + +import co.touchlab.kermit.LogWriter +import co.touchlab.kermit.NSLogWriter +import co.touchlab.kermit.OSLogWriter +import fr.acinq.phoenix.utils.PlatformContext + +actual fun phoenixLogWriters(ctx: PlatformContext): List { + return if (ctx.logger != null) { + listOf(NSLogWriter()) + } else { + // TODO: OSLogWriter is disabled for now, as the current version of OSLogStore is buggy + listOf(OSLogWriter()) + } +} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/ac/cord/auxiliary/compose/AppVersion.jvm.kt b/composeApp/src/jvmMain/kotlin/ac/cord/auxiliary/compose/AppVersion.jvm.kt new file mode 100644 index 00000000..65ea6ee4 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/ac/cord/auxiliary/compose/AppVersion.jvm.kt @@ -0,0 +1,8 @@ +package ac.cord.auxiliary.compose + +actual object AppVersion { + actual val versionName: String + get() = "0.0.1" + actual val versionCode: String + get() = "21" +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cb71ec22..ffa7d415 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,7 @@ kermit = "2.1.0" kotlin = "2.3.21" kotlinx-coroutines = "1.11.0" kotlinx-datetime = "0.8.0" -kotlinxSerializationJson = "1.11.0" +kotlinxSerialization = "1.11.0" ksp = "2.3.6" ktor = "3.5.0" lightningKmpCore = "1.11.5" @@ -31,7 +31,9 @@ okio = "3.17.0" pagingCommon = "3.5.0" quartz = "1.10.0" room3 = "3.0.0-alpha06" +sqldelight = "2.3.2" sqlite = "2.6.2" +workRuntimeKtx = "2.11.1" [libraries] androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } @@ -47,6 +49,7 @@ androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.r androidx-room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" } androidx-room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" } androidx-room3-sqlite-wrapper = { module = "androidx.room3:room3-sqlite-wrapper", version.ref = "room3" } +androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" } @@ -66,7 +69,8 @@ kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotl kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } -kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } +kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinxSerialization" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } @@ -75,6 +79,10 @@ lightning-kmp-core = { module = "fr.acinq.lightning:lightning-kmp-core", version navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" } okhttp-coroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" } +sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } +sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } +sqldelight-native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" } +sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" } vitorpamplona-quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } [plugins] @@ -86,4 +94,5 @@ composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMul composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlinPluginSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } -ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } \ No newline at end of file +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } \ No newline at end of file