From e9510c4ea5420ac34d6ac10daa32a2838227c1ee Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 2 Aug 2026 18:58:36 +0200 Subject: [PATCH] Remove phoenix code --- .../acinq/phoenix/android/BusinessManager.kt | 324 --------- .../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 | 165 ----- .../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 | 335 --------- .../phoenix/utils/converters/DateFormatter.kt | 55 -- .../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 ---- .../commonMain/kotlin/fr/acinq/conf/Lsp.kt | 62 -- .../commonMain/kotlin/fr/acinq/conf/Seed.kt | 36 - .../kotlin/fr/acinq/conf/SeedSpec.kt | 9 - .../kotlin/fr/acinq/phoenix/Ambients.kt | 60 -- .../fr/acinq/phoenix/PhoenixBusiness.kt | 185 ----- .../kotlin/fr/acinq/phoenix/PhoenixGlobal.kt | 61 -- .../phoenix/controllers/AppController.kt | 78 -- .../phoenix/controllers/ControllerFactory.kt | 33 - .../fr/acinq/phoenix/controllers/MVI.kt | 32 - .../config/CloseChannelsConfiguration.kt | 42 -- .../CloseChannelsConfigurationController.kt | 172 ----- .../controllers/config/Configuration.kt | 13 - .../config/ConfigurationController.kt | 34 - .../config/ElectrumConfiguration.kt | 26 - .../config/ElectrumConfigurationController.kt | 60 -- .../controllers/init/InitController.kt | 34 - .../controllers/init/Initialization.kt | 29 - .../phoenix/controllers/init/RestoreWallet.kt | 78 -- .../init/RestoreWalletController.kt | 64 -- .../acinq/phoenix/controllers/main/Content.kt | 15 - .../controllers/main/ContentController.kt | 38 - .../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 | 288 -------- .../fr/acinq/phoenix/data/AppConfiguration.kt | 296 -------- .../fr/acinq/phoenix/data/BitcoinAddress.kt | 66 -- .../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 | 174 ----- .../fr/acinq/phoenix/data/LocalChannelInfo.kt | 194 ----- .../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 | 152 ---- .../fr/acinq/phoenix/data/lnurl/Lnurl.kt | 264 ------- .../fr/acinq/phoenix/data/lnurl/LnurlAuth.kt | 177 ----- .../fr/acinq/phoenix/data/lnurl/LnurlError.kt | 68 -- .../fr/acinq/phoenix/data/lnurl/LnurlPay.kt | 226 ------ .../acinq/phoenix/data/lnurl/LnurlWithdraw.kt | 33 - .../kotlin/fr/acinq/phoenix/db/DbFactory.kt | 26 - .../kotlin/fr/acinq/phoenix/db/DbHooks.kt | 52 -- .../fr/acinq/phoenix/db/DbInitHelper.kt | 146 ---- .../kotlin/fr/acinq/phoenix/db/SqliteAppDb.kt | 175 ----- .../fr/acinq/phoenix/db/SqliteChannelsDb.kt | 85 --- .../fr/acinq/phoenix/db/SqlitePaymentsDb.kt | 342 --------- .../fr/acinq/phoenix/db/cloud/CloudHelper.kt | 18 - .../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 | 139 ---- .../phoenix/db/cloud/payments/CloudData.kt | 157 ----- .../InboundLiquidityPaymentWrapper.kt | 146 ---- .../IncomingPaymentWrapperV10Legacy.kt | 46 -- .../payments/LightningOutgoingPartType.kt | 68 -- .../cloud/payments/LightningOutgoingType.kt | 107 --- .../cloud/payments/SpliceCpfpPaymentType.kt | 37 - .../db/cloud/payments/SpliceOutgoingType.kt | 40 -- .../phoenix/db/contacts/ContactQueries.kt | 75 -- .../phoenix/db/contacts/SqliteContactsDb.kt | 171 ----- .../db/migrations/appDb/v7/AfterVersion7.kt | 353 ---------- .../db/migrations/v10/AfterVersion10.kt | 100 --- .../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 | 503 ------------- .../db/migrations/v11/AfterVersion11.kt | 476 ------------- .../queries/ChannelCloseOutgoingQueries.kt | 60 -- .../v11/queries/InboundLiquidityQueries.kt | 113 --- .../v11/queries/LightningOutgoingQueries.kt | 224 ------ .../v11/queries/SpliceCpfpOutgoingQueries.kt | 46 -- .../v11/queries/SpliceOutgoingQueries.kt | 51 -- .../v11/types/OutgoingDetailsType.kt | 100 --- .../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 | 115 --- .../db/notifications/NotificationsQueries.kt | 168 ----- .../phoenix/db/payments/CloudKitInterface.kt | 4 - .../phoenix/db/payments/MetadataTypes.kt | 330 --------- .../db/payments/PaymentsMetadataQueries.kt | 155 ---- .../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 | 468 ------------ .../acinq/phoenix/managers/BalanceManager.kt | 102 --- .../phoenix/managers/ConnectionsManager.kt | 68 -- .../phoenix/managers/DataStoreManager.kt | 143 ---- .../acinq/phoenix/managers/DatabaseManager.kt | 129 ---- .../fr/acinq/phoenix/managers/LnurlManager.kt | 197 ------ .../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 | 232 ------ .../fr/acinq/phoenix/managers/PeerManager.kt | 297 -------- .../fr/acinq/phoenix/managers/PinManager.kt | 142 ---- .../fr/acinq/phoenix/managers/SeedManager.kt | 183 ----- .../fr/acinq/phoenix/managers/SendManager.kt | 665 ------------------ .../acinq/phoenix/managers/WalletManager.kt | 134 ---- .../managers/global/CurrencyManager.kt | 423 ----------- .../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 | 72 -- .../phoenix/security/EncryptedPinSpending.kt | 78 -- .../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 | 80 --- .../acinq/phoenix/utils/MnemonicLanguage.kt | 83 --- .../kotlin/fr/acinq/phoenix/utils/Parser.kt | 231 ------ .../fr/acinq/phoenix/utils/PlatformContext.kt | 25 - .../utils/channels/ChannelsImportHelper.kt | 68 -- .../channels/SpendChannelAddressHelper.kt | 126 ---- .../utils/converters/AmountConverter.kt | 137 ---- .../utils/converters/AmountFormatter.kt | 121 ---- .../utils/extensions/ChainExtensions.kt | 16 - .../utils/extensions/ChannelExtensions.kt | 91 --- .../utils/extensions/ConnectionExtensions.kt | 33 - .../utils/extensions/MiscExtensions.kt | 31 - .../utils/extensions/PaymentExtensions.kt | 76 -- .../extensions/PaymentRequestExtensions.kt | 42 -- .../utils/extensions/TechnicalExtensions.kt | 24 - .../utils/extensions/WalletStateExtensions.kt | 48 -- .../phoenix/utils/logger/LoggerConfig.kt | 40 -- .../utils/migrations/IosMigrationHelper.kt | 173 ----- .../migrations/LegacyChannelCloseHelper.kt | 94 --- .../phoenix/utils/preferences/GlobalPrefs.kt | 132 ---- .../utils/preferences/InternalPrefs.kt | 115 --- .../phoenix/utils/preferences/UserPrefs.kt | 389 ---------- .../preferences/UserPrefsComposeExtensions.kt | 41 -- .../acinq/phoenix/data/ElectrumServers.ios.kt | 6 - .../fr/acinq/phoenix/db/CloudKitContactsDb.kt | 319 --------- .../kotlin/fr/acinq/phoenix/db/CloudKitDb.kt | 14 - .../fr/acinq/phoenix/db/CloudKitPaymentsDb.kt | 385 ---------- .../fr/acinq/phoenix/db/DbFactory.ios.kt | 89 --- .../kotlin/fr/acinq/phoenix/db/DbHooks.ios.kt | 36 - .../fr/acinq/phoenix/ios/BusinessManager.kt | 311 -------- .../phoenix/managers/DataStoreManager.ios.kt | 27 - .../managers/global/NetworkMonitor.ios.kt | 93 --- .../fr/acinq/phoenix/security/CCCipher.kt | 210 ------ .../acinq/phoenix/security/KeyChainHelper.kt | 259 ------- .../phoenix/security/KeyStoreFunctions.ios.kt | 25 - .../phoenix/utils/PlatformContext.ios.kt | 31 - .../extensions/TechnicalExtensions.ios.kt | 15 - .../phoenix/utils/logger/LoggerConfig.ios.kt | 15 - 205 files changed, 22110 deletions(-) delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/BusinessManager.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/BootReceiver.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/ChannelsWatcher.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/ContactsPhotoCleaner.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/DailyConnect.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/HceService.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/InflightPaymentsWatcher.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/PaymentsForegroundService.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/data/ElectrumServers.android.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/db/DbFactory.android.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/db/DbHooks.android.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/managers/DataStoreManager.android.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/managers/global/NetworkMonitor.android.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/security/KeyStoreFunctions.android.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/security/KeystoreHelper.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/ContactsPhotoHelper.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/Logging.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/PlatformContext.android.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/SystemNotificationHelper.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/converters/DateFormatter.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/extensions/AndroidContextExtensions.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.android.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/logger/LoggerConfig.android.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/ApduCommands.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/NfcHelper.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/NfcParser.kt delete mode 100644 composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/NfcReaderCallback.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/conf/Lsp.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/conf/Seed.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/conf/SeedSpec.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/Ambients.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/PhoenixBusiness.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/PhoenixGlobal.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/AppController.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/ControllerFactory.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/MVI.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/CloseChannelsConfiguration.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/CloseChannelsConfigurationController.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/Configuration.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/ConfigurationController.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/ElectrumConfiguration.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/ElectrumConfigurationController.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/InitController.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/Initialization.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/RestoreWallet.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/RestoreWalletController.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/Content.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/ContentController.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/Home.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/HomeController.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/payments/Receive.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/payments/ReceiveController.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/csv/CsvWriter.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/csv/WalletPaymentCsvWriter.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/AppConfiguration.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/BitcoinAddress.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ChannelsWatcherOutcome.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ContactInfo.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/DecryptSeedResult.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/DefaultOffer.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ElectrumServers.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ExchangeRates.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/LocalChannelInfo.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/MempoolFeerate.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/Notification.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/StartBusinessResult.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/UserTheme.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/Wallet.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/WalletContext.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/WalletNotice.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/WalletPayment.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/Lnurl.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlAuth.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlError.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlPay.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlWithdraw.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbFactory.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbHooks.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbInitHelper.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/SqliteAppDb.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/SqliteChannelsDb.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/SqlitePaymentsDb.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/CloudHelper.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/CloudSerializers.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/contacts/CloudContact.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/ChannelCloseType.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/CloudAsset.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/CloudData.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/InboundLiquidityPaymentWrapper.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/IncomingPaymentWrapperV10Legacy.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/LightningOutgoingPartType.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/LightningOutgoingType.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/SpliceCpfpPaymentType.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/SpliceOutgoingType.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/contacts/ContactQueries.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/contacts/SqliteContactsDb.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/appDb/v7/AfterVersion7.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/AfterVersion10.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/AbstractStringSerializer.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/ByteVectorSerializer.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/MilliSatoshiSerializer.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/OutpointSerializer.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/SatoshiSerializer.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/TxIdSerializer.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/UUIDSerializer.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/types/IncomingTypes.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/AfterVersion11.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/ChannelCloseOutgoingQueries.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/InboundLiquidityQueries.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/LightningOutgoingQueries.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/SpliceCpfpOutgoingQueries.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/SpliceOutgoingQueries.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingDetailsType.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingPartClosingType.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingPartStatusType.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingStatusType.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/FundingFeeData.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/LegacyLeaseData.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/PaymentDetailsData.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/PurchaseData.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/notifications/NotificationDataType.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/notifications/NotificationsQueries.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/CloudKitInterface.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/MetadataTypes.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/PaymentsMetadataQueries.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/SqliteIncomingPaymentsDb.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/SqliteOutgoingPaymentsDb.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/serialization/contacts/Serialization.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/serialization/contacts/v1/Deserialization.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/serialization/contacts/v1/Serialization.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/AppConfigurationManager.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/AppConnectionsDaemon.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/BalanceManager.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/ConnectionsManager.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/DataStoreManager.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/DatabaseManager.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/LnurlManager.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/NodeParamsManager.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/NotificationsManager.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PaymentMetadataQueue.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PaymentsManager.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PaymentsPageFetcher.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PeerManager.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PinManager.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/SeedManager.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/SendManager.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/WalletManager.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/CurrencyManager.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/FeerateManager.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/NetworkMonitor.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/WalletContextManager.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/BlockchainInfoApi.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/BluelyticsApi.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/CoinbaseApi.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/ExchangeRateApi.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/YadioApi.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedData.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedPin.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedPinLock.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedPinSpending.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedSeed.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/KeyStoreFunctions.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/KeyStoreNames.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/BlockchainExplorer.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/Cache.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/DateUtils.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/DnsResolvers.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/MnemonicLanguage.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/Parser.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/PlatformContext.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/channels/ChannelsImportHelper.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/channels/SpendChannelAddressHelper.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/converters/AmountConverter.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/converters/AmountFormatter.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/ChainExtensions.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/ChannelExtensions.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/ConnectionExtensions.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/MiscExtensions.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/PaymentExtensions.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/PaymentRequestExtensions.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/WalletStateExtensions.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/logger/LoggerConfig.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/migrations/IosMigrationHelper.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/migrations/LegacyChannelCloseHelper.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/GlobalPrefs.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/InternalPrefs.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/UserPrefs.kt delete mode 100644 composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/UserPrefsComposeExtensions.kt delete mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/data/ElectrumServers.ios.kt delete mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitContactsDb.kt delete mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitDb.kt delete mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitPaymentsDb.kt delete mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/DbFactory.ios.kt delete mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/DbHooks.ios.kt delete mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/ios/BusinessManager.kt delete mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/managers/DataStoreManager.ios.kt delete mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/managers/global/NetworkMonitor.ios.kt delete mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/security/CCCipher.kt delete mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/security/KeyChainHelper.kt delete mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/security/KeyStoreFunctions.ios.kt delete mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/utils/PlatformContext.ios.kt delete mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.ios.kt delete mode 100644 composeApp/src/iosMain/kotlin/fr/acinq/phoenix/utils/logger/LoggerConfig.ios.kt diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/BusinessManager.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/BusinessManager.kt deleted file mode 100644 index 21a004c2..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/BusinessManager.kt +++ /dev/null @@ -1,324 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.android - -import android.content.Context -import android.text.format.DateUtils -import press.mantra.android.MantraApplication -import press.mantra.compose.AppVersion -import press.mantra.compose.ui.composable.widgets.wallet.WalletAvatars -import fr.acinq.lightning.LiquidityEvents -import fr.acinq.lightning.PaymentEvents -import fr.acinq.lightning.utils.Connection -import fr.acinq.lightning.utils.currentTimestampMillis -import fr.acinq.phoenix.BusinessMonitorJobs -import fr.acinq.phoenix.BusinessRunning -import fr.acinq.phoenix.PhoenixBusiness -import fr.acinq.phoenix.android.services.InflightPaymentsWatcher -import fr.acinq.phoenix.data.StartBusinessResult -import fr.acinq.phoenix.data.StartupParams -import fr.acinq.phoenix.data.WalletId -import fr.acinq.phoenix.data.inFlightPaymentsCount -import fr.acinq.phoenix.managers.AppConnectionsDaemon -import fr.acinq.phoenix.managers.NodeParamsManager -import fr.acinq.phoenix.managers.PeerManager -import fr.acinq.phoenix.managers.global.CurrencyManager -import fr.acinq.phoenix.utils.MnemonicLanguage -import fr.acinq.phoenix.utils.SystemNotificationHelper -import fr.acinq.phoenix.utils.preferences.InternalPrefs -import fr.acinq.phoenix.utils.preferences.UserPrefs -import fr.acinq.phoenix.utils.preferences.UserWalletMetadata -import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.slf4j.LoggerFactory -import kotlin.time.Duration.Companion.hours - -object BusinessManager { - - private val log = LoggerFactory.getLogger(this::class.java) - private val supervisor = SupervisorJob() - private val scope = CoroutineScope(Dispatchers.Default + supervisor) - private val startupMutex = Mutex() - - // No memory leaks because this can only contain the application context. - private lateinit var appContext: Context - private val application by lazy { appContext as MantraApplication } - - /** A map of (walletId -> active businesses) */ - private val _businessFlow = MutableStateFlow>(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 deleted file mode 100644 index 1941973e..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/BootReceiver.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2019 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.android.services - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent - -/** - * This receiver is started when the device has booted, and schedules background jobs. - */ -class BootReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (Intent.ACTION_BOOT_COMPLETED == intent.action) { - ChannelsWatcher.schedule(context) - InflightPaymentsWatcher.scheduleOnce(context) - DailyConnect.schedule(context) - } - } -} 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 deleted file mode 100644 index 4d005ac8..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/ChannelsWatcher.kt +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Copyright 2023 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.android.services - -import android.content.Context -import androidx.work.CoroutineWorker -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.ExistingWorkPolicy -import androidx.work.OneTimeWorkRequest -import androidx.work.Operation -import androidx.work.PeriodicWorkRequest -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import press.mantra.android.BuildConfig -import fr.acinq.lightning.channel.states.Closing -import fr.acinq.lightning.utils.currentTimestampMillis -import fr.acinq.phoenix.PhoenixGlobal -import fr.acinq.phoenix.android.BusinessManager -import fr.acinq.phoenix.data.ChannelsWatcherOutcome -import fr.acinq.phoenix.data.StartBusinessResult -import fr.acinq.phoenix.data.WalletId -import fr.acinq.phoenix.data.WatchTowerOutcome -import fr.acinq.phoenix.managers.AppConnectionsDaemon -import fr.acinq.phoenix.managers.DataStoreManager -import fr.acinq.phoenix.managers.NodeParamsManager.Companion.chain -import fr.acinq.phoenix.managers.SeedManager -import fr.acinq.phoenix.utils.PlatformContext -import fr.acinq.phoenix.utils.SystemNotificationHelper -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.withTimeout -import org.slf4j.LoggerFactory -import java.util.concurrent.TimeUnit - - -/** Worker that monitors channels in the background. Triggers a user-facing notification when an unexpected spending is detected. */ -class ChannelsWatcher(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { - - override suspend fun doWork(): Result { - log.info("starting $name") - - if (BusinessManager.businessFlow.first().isNotEmpty()) { - log.info("there already are active businesses, aborting $name") - return Result.success() - } - - val userWallets = SeedManager.loadAndDecryptOrNull( - phoenixGlobal = PhoenixGlobal( - ctx = PlatformContext( - applicationContext - ) - ) - ) - if (userWallets.isNullOrEmpty()) { - log.info("could not load any seed, aborting $name") - return Result.success() - } - - val watchResult = userWallets.map { (walletId, wallet) -> - watchWallet(walletId, wallet.words) - } - - BusinessManager.stopAllHeadlessBusinesses() - - return if (watchResult.all { it }) { - log.info("finished $name, watchers have all terminated successfully") - Result.success() - } else { - log.info("finished $name, one or more watchers encountered an error") - Result.failure() - } - } - - /** Watches channels for a given node id ; return false if an error occurred. */ - private suspend fun watchWallet(walletId: WalletId, words: List): 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 deleted file mode 100644 index 75cba8ac..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/ContactsPhotoCleaner.kt +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.android.services - -import android.content.Context -import androidx.work.CoroutineWorker -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.ExistingWorkPolicy -import androidx.work.OneTimeWorkRequest -import androidx.work.PeriodicWorkRequest -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import press.mantra.android.BuildConfig -import fr.acinq.phoenix.PhoenixGlobal -import fr.acinq.phoenix.android.BusinessManager -import fr.acinq.phoenix.data.StartBusinessResult -import fr.acinq.phoenix.managers.SeedManager -import fr.acinq.phoenix.utils.ContactsPhotoHelper -import fr.acinq.phoenix.utils.PlatformContext -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import org.slf4j.LoggerFactory -import java.io.File -import java.util.concurrent.TimeUnit - -/** Clean up unused photo files for contacts. */ -class ContactsPhotoCleaner(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { - private val log = LoggerFactory.getLogger(this::class.java) - - override suspend fun doWork(): Result { - log.info("starting $name") - try { - - // get the active business map, or create it no business is active - val businessMap = BusinessManager.businessFlow.value.takeIf { it.isNotEmpty() } - ?: run { - val userWallets = SeedManager.loadAndDecryptOrNull( - phoenixGlobal = PhoenixGlobal( - ctx = PlatformContext( - applicationContext - ) - ) - ) - if (userWallets.isNullOrEmpty()) { - log.info("no seeds available, terminating $name") - return Result.success() - } - - userWallets.map { (walletId, wallet) -> - val res = BusinessManager.startNewBusiness(words = wallet.words, isHeadless = true) - if (res is StartBusinessResult.Failure) { - log.info("failed to start business for wallet=$walletId") - return Result.failure() - } - - val business = BusinessManager.businessFlow.value[walletId] - if (business == null) { - log.info("failed to access business for wallet=$walletId") - return Result.success() - } - - walletId to business - }.toMap() - } - - val contactsPhotoDir = ContactsPhotoHelper.contactsDir(applicationContext) - val toDelete = businessMap.map { (_, running) -> - val contacts = running.business.databaseManager.contactsList.filterNotNull().first() - val contactsPhotoNames = contactsPhotoDir.listFiles()?.map { it.name }?.toSet() ?: emptySet() - contactsPhotoNames.subtract(contacts.map { it.photoUri }.toSet()).filterNotNull() - }.flatten() - - log.debug("info ${toDelete.size} unused photo file(s)") - toDelete.forEach { imageName -> - File(contactsPhotoDir, imageName).takeIf { it.exists() && it.isFile && it.canWrite() }?.delete() - } - - return Result.success() - } catch (e: Exception) { - log.error("error in $name: ", e) - return Result.failure() - } finally { - log.debug("finished $name") - } - } - - companion object { - private val log = LoggerFactory.getLogger(this::class.java) - private const val name = "contacts-photo-cleaner" - private const val TAG = BuildConfig.APPLICATION_ID + ".ContactPhotoCleaner" - - /** Schedule [ContactsPhotoCleaner] to run roughly every 2 weeks. */ - fun schedule(context: Context) { - val work = PeriodicWorkRequest.Builder(ContactsPhotoCleaner::class.java, 15, TimeUnit.DAYS, 3, TimeUnit.DAYS).addTag(TAG) - WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, work.build()) - } - - fun scheduleASAP(context: Context) { - log.info("scheduling $name once") - val work = OneTimeWorkRequest.Builder(ContactsPhotoCleaner::class.java).addTag(TAG).build() - WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, work) - } - } -} - 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 deleted file mode 100644 index 8f4c68a2..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/DailyConnect.kt +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.android.services - -import android.content.Context -import androidx.work.CoroutineWorker -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.ExistingWorkPolicy -import androidx.work.OneTimeWorkRequest -import androidx.work.Operation -import androidx.work.PeriodicWorkRequest -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import press.mantra.android.BuildConfig -import fr.acinq.lightning.utils.Connection -import fr.acinq.phoenix.PhoenixGlobal -import fr.acinq.phoenix.android.BusinessManager -import fr.acinq.phoenix.data.StartBusinessResult -import fr.acinq.phoenix.managers.SeedManager -import fr.acinq.phoenix.utils.PlatformContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.slf4j.LoggerFactory -import java.util.concurrent.TimeUnit - -/** - * This worker is scheduled to run roughly every day. It simply connects to the LSP, wait for 1 minute, - * then shuts down. The purpose is to settle pending payments that may have been missed by the - * [InflightPaymentsWatcher], to complete closings properly, etc... - */ -class DailyConnect(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { - - private val log = LoggerFactory.getLogger(this::class.java) - - override suspend fun doWork(): Result { - log.info("starting $name") - - if (BusinessManager.businessFlow.first().isNotEmpty()) { - log.info("there already are active businesses, aborting $name") - return Result.success() - } - - val userWallets = SeedManager.loadAndDecryptOrNull( - phoenixGlobal = PhoenixGlobal( - ctx = PlatformContext( - applicationContext - ) - ) - ) - if (userWallets.isNullOrEmpty()) { - log.info("could not load any seed, aborting $name") - return Result.success() - } - - try { - val businessMap = userWallets.map { (walletId, wallet) -> - val res = BusinessManager.startNewBusiness(words = wallet.words, isHeadless = true) - if (res is StartBusinessResult.Failure) { - log.info("failed to start business for wallet=$walletId") - return Result.success() - } - - val business = BusinessManager.businessFlow.value[walletId] - if (business == null) { - log.info("failed to access business for wallet=$walletId") - return Result.success() - } - - walletId to business - }.toMap() - - withContext(Dispatchers.Default) { - val stopJobSignal = MutableStateFlow(false) - - val watchers = businessMap.map { (walletId, running) -> - val business = running.business - launch { - business.appConnectionsDaemon?.forceReconnect() - business.connectionsManager.connections.first { it.global is Connection.ESTABLISHED } - log.debug("connections established for wallet={}", walletId) - - business.peerManager.channelsFlow.filterNotNull().collect { channels -> - when { - channels.isEmpty() -> { - log.info("no channels found for wallet=$walletId") - stopJobSignal.value = true - } - else -> { - log.info("${channels.size} channel(s) found for wallet=$walletId, waiting 60s...") - delay(60_000) - stopJobSignal.value = true - } - } - } - }.also { - it.invokeOnCompletion { log.debug("completed watching-channels job for wallet={} ({})", walletId, it?.localizedMessage) } - } - } - stopJobSignal.first { it } - log.debug("stop-job signal detected") - watchers.forEach { it.cancelAndJoin() } - } - - return Result.success() - - } catch (e: Exception) { - log.error("error in $name: ", e) - return Result.failure() - } finally { - BusinessManager.stopAllHeadlessBusinesses() - log.info("finished $name") - } - } - - companion object { - private val log = LoggerFactory.getLogger(this::class.java) - private val name = "daily-connect-job" - const val TAG = BuildConfig.APPLICATION_ID + ".DailyConnect" - - /** Schedule [DailyConnect] to run roughly every day. */ - fun schedule(context: Context) { - log.info("scheduling $name") - val work = PeriodicWorkRequest.Builder(DailyConnect::class.java, 36, TimeUnit.HOURS, 12, TimeUnit.HOURS).addTag(TAG) - WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, work.build()) - } - - fun scheduleASAP(context: Context) { - log.info("scheduling $name once") - val work = OneTimeWorkRequest.Builder(DailyConnect::class.java).addTag(TAG).build() - WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, work) - } - - /** Cancel all scheduled in-flight payments worker. */ - fun cancel(context: Context): Operation { - return WorkManager.getInstance(context).cancelAllWorkByTag(TAG) - } - } -} - 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 deleted file mode 100644 index eaf58007..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/HceService.kt +++ /dev/null @@ -1,198 +0,0 @@ -/* - * Copyright 2023 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.android.services - -import android.app.Service -import android.content.Intent -import android.nfc.NdefMessage -import android.nfc.cardemulation.HostApduService -import android.os.Bundle -import com.machankura.compose.ui.composable.widgets.nfc.NfcState -import com.machankura.compose.ui.composable.widgets.nfc.NfcStateRepository -import fr.acinq.bitcoin.byteVector -import fr.acinq.phoenix.utils.nfc.ApduCommands -import fr.acinq.phoenix.utils.nfc.ApduCommands.A_ERROR -import fr.acinq.phoenix.utils.nfc.ApduCommands.A_OKAY -import fr.acinq.phoenix.utils.nfc.ApduCommands.NDEF_FILE -import fr.acinq.phoenix.utils.nfc.ApduCommands.READ_CC -import fr.acinq.phoenix.utils.nfc.ApduCommands.READ_CC_RESPONSE -import fr.acinq.phoenix.utils.nfc.ApduCommands.READ_NDEF_BINARY -import fr.acinq.phoenix.utils.nfc.ApduCommands.READ_NDEF_BINARY_LENGTH -import fr.acinq.phoenix.utils.nfc.ApduCommands.SELECT_AID -import fr.acinq.phoenix.utils.nfc.ApduCommands.SELECT_AID_LE -import fr.acinq.phoenix.utils.nfc.ApduCommands.SELECT_CC_FILE -import fr.acinq.phoenix.utils.nfc.ApduCommands.SELECT_NDEF_FILE -import fr.acinq.phoenix.utils.nfc.NfcHelper -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import org.slf4j.LoggerFactory - - -/** - * This service emulates a NDEF type 4 tag. See the NFC Forum specs section 5.4 for the expected - * sequence of messages/responses. We hardcode most of them in [ApduCommands]. - * - * Uses [NfcStateRepository] to pass the payment request to emit. - * - * The message will be a Ndef text record with TNF WELL-KNOWN. - */ -class HceService : HostApduService() { - - private val log = LoggerFactory.getLogger(this::class.java) - - private val serviceScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) - private var ndefMessage: HceMessage? = null - - override fun onCreate() { - super.onCreate() - log.debug("creating hce service") - } - - override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { - super.onStartCommand(intent, flags, startId) - log.debug("onStartCommand") - serviceScope.launch { - NfcStateRepository.state.collect { - ndefMessage = when (it) { - is NfcState.EmulatingTag -> HceMessage(it.paymentRequest) - else -> null - } - } - } - return START_NOT_STICKY - } - - @OptIn(ExperimentalStdlibApi::class) - override fun processCommandApdu(commandApdu: ByteArray?, extras: Bundle?): ByteArray { - log.debug("processing apdu command={} extras={}", commandApdu?.byteVector()?.toHex(), extras) - - if (commandApdu == null) return A_ERROR - val nfcState = NfcStateRepository.state.value - if (nfcState is NfcState.Inactive) { - log.debug("trying to emulate tag in state={}, aborting", nfcState) - return A_ERROR - } - val message = ndefMessage ?: return A_ERROR - - return when { - SELECT_AID.contentEquals(commandApdu) or SELECT_AID_LE.contentEquals(commandApdu) -> { - log.debug("selecting ndef tag AID, returning {}", A_OKAY.toHexString()) - A_OKAY - } - - SELECT_CC_FILE.contentEquals(commandApdu) -> { - log.debug("selecting capability container, returning {}", A_OKAY.toHexString()) - A_OKAY - } - - READ_CC.contentEquals(commandApdu) -> { - log.debug("selecting capability container file, returning {}", READ_CC_RESPONSE.toHexString()) - READ_CC_RESPONSE - } - - SELECT_NDEF_FILE.contentEquals(commandApdu) -> { - log.debug("selecting ndef file, returning {}", A_OKAY.toHexString()) - A_OKAY - } - - READ_NDEF_BINARY_LENGTH.contentEquals(commandApdu) -> { - val response = message.messageLength + A_OKAY - log.debug("reading ndef message length, returning {}", response.toHexString()) - response - } - - commandApdu.size > 2 && commandApdu.sliceArray(0..1).contentEquals(READ_NDEF_BINARY) -> { - // we may receive several commands in a row if the message is large enough - val response = getBinaryResponse(commandApdu, message) - val expectedResponse = message.message.records[0].payload.toHexString() + A_OKAY.toHexString() - if (expectedResponse.contains(response.toHexString())) { - log.info("done sending payment request via nfc: ${message.paymentRequest}") - NfcStateRepository.updateState(NfcState.Inactive) - } - log.debug("reading ndef message, returning {}", response.toHexString()) - response - } - - else -> { - log.debug("unhandled apdu command={}", commandApdu) - A_ERROR - } - } - } - - @OptIn(ExperimentalStdlibApi::class) - private fun getBinaryResponse(commandApdu: ByteArray, message: HceMessage): ByteArray { - try { - if (commandApdu.size < 5) { - log.error("invalid APDU: too short (${commandApdu.size} bytes)") - return A_ERROR - } - - // the full response before applying offset - val response = message.messageLength + message.messageBytes - - // the reader may want the data in chunks - val offset = commandApdu.sliceArray(2..3).toHexString().toInt(16) - val chunkSize = commandApdu[4].toInt() and 0xFF - log.debug("offset=$offset length=$chunkSize for full_response=${response.toHexString()} =") - - if (offset >= response.size) { - log.error("offset $offset is beyond full response size ${response.size}") - return A_ERROR - } - - val responseAfterOffset = response.sliceArray(offset until response.size) - val chunkSizeForOffset = minOf(chunkSize, responseAfterOffset.size) - val chunk = responseAfterOffset.copyOfRange(0, chunkSizeForOffset) + A_OKAY - - return chunk - } catch (e: Exception) { - log.error("error when getting binary response: {}", e.localizedMessage) - return A_ERROR - } - } - - override fun onDeactivated(reason: Int) { - when (reason) { - DEACTIVATION_DESELECTED -> log.info("deactivation: different AID selected") - DEACTIVATION_LINK_LOSS -> log.info("deactivation: link lost") - else -> log.info("deactivation: code $reason") - } - } - - override fun onDestroy() { - super.onDestroy() - log.info("service removed") - if (NfcStateRepository.state.value is NfcState.EmulatingTag) { - NfcStateRepository.updateState(NfcState.Inactive) - } - } - - private data class HceMessage(val paymentRequest: String) { - val message by lazy { NdefMessage(NfcHelper.createTextRecord(paymentRequest, NDEF_FILE)) } - val messageBytes: ByteArray by lazy { message.toByteArray() } - val messageLength by lazy { - NfcHelper.fillByteArrayToFixedDimension(messageBytes.size.toBigInteger().toByteArray(), 2) - } - - override fun toString(): String { - return "message=$paymentRequest length=$messageLength tnf=${message.records.firstOrNull()?.tnf} type=${message.records.firstOrNull()?.type}" - } - } -} 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 deleted file mode 100644 index 4d699378..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/InflightPaymentsWatcher.kt +++ /dev/null @@ -1,226 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.android.services - -import android.content.Context -import android.os.Build -import androidx.work.CoroutineWorker -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.ExistingWorkPolicy -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.Operation -import androidx.work.PeriodicWorkRequest -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import press.mantra.android.BuildConfig -import press.mantra.android.MantraApplication -import fr.acinq.lightning.channel.states.Syncing -import fr.acinq.lightning.utils.Connection -import fr.acinq.phoenix.PhoenixGlobal -import fr.acinq.phoenix.android.BusinessManager -import fr.acinq.phoenix.data.LocalChannelInfo -import fr.acinq.phoenix.data.StartBusinessResult -import fr.acinq.phoenix.data.WalletId -import fr.acinq.phoenix.data.inFlightPaymentsCount -import fr.acinq.phoenix.managers.DataStoreManager -import fr.acinq.phoenix.managers.NodeParamsManager.Companion.chain -import fr.acinq.phoenix.managers.SeedManager -import fr.acinq.phoenix.utils.PlatformContext -import fr.acinq.phoenix.utils.SystemNotificationHelper -import fr.acinq.phoenix.utils.preferences.UserWalletMetadata -import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collectIndexed -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.slf4j.LoggerFactory -import java.util.concurrent.TimeUnit -import kotlin.time.Duration -import kotlin.time.Duration.Companion.hours -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds -import kotlin.time.toJavaDuration - -/** - * This worker starts a node to settle any pending in-flight payments. This will prevent payment timeouts - * (and channels force-close) in case the app is not started regularly and the silent push notifications - * sent by the ACINQ peer are ignored by the device. - * - * Example: devices using GrapheneOS, where FCM is not supported. - * - * This service is scheduled whenever there's a pending htlc in a channel. - * See [LocalChannelInfo.inFlightPaymentsCount]. - */ -class InflightPaymentsWatcher(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { - - private val log = LoggerFactory.getLogger(this::class.java) - - override suspend fun doWork(): Result { - log.info("starting $name") - - if (BusinessManager.businessFlow.first().isNotEmpty()) { - log.debug("there already are active businesses, aborting $name") - return Result.success() - } - - val userWallets = SeedManager.loadAndDecryptOrNull( - phoenixGlobal = PhoenixGlobal( - ctx = PlatformContext( - applicationContext - ) - ) - ) - if (userWallets.isNullOrEmpty()) { - log.debug("could not load any seed, aborting $name") - return Result.success() - } - - val watchResult = userWallets.map { (walletId, wallet) -> - val walletMetadata = (applicationContext as MantraApplication).globalPrefs.getAvailableWalletsMeta.first().getByWalletIdOrDefault(walletId) - watchWallet(walletId, walletMetadata, wallet.words) - } - - BusinessManager.stopAllHeadlessBusinesses() - - return if (watchResult.all { it }) { - log.info("finished $name, watchers have all terminated successfully") - Result.success() - } else { - log.info("finished $name, one or more watchers encountered an error") - Result.failure() - } - } - - private suspend fun watchWallet(walletId: WalletId, walletMetadata: UserWalletMetadata, words: List): 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 deleted file mode 100644 index d7bb2096..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/android/services/PaymentsForegroundService.kt +++ /dev/null @@ -1,165 +0,0 @@ -package fr.acinq.phoenix.android.services - -import android.app.Service -import android.content.Intent -import android.os.Handler -import android.os.Looper -import androidx.core.app.NotificationManagerCompat -import press.mantra.android.BuildConfig -import press.mantra.android.MantraApplication -import fr.acinq.lightning.utils.Connection -import fr.acinq.phoenix.PhoenixBusiness -import fr.acinq.phoenix.PhoenixGlobal -import fr.acinq.phoenix.android.BusinessManager -import fr.acinq.phoenix.data.DecryptSeedResult -import fr.acinq.phoenix.data.StartBusinessResult -import fr.acinq.phoenix.data.WalletId -import fr.acinq.phoenix.managers.AppConnectionsDaemon -import fr.acinq.phoenix.managers.SeedManager -import fr.acinq.phoenix.utils.PlatformContext -import fr.acinq.phoenix.utils.SystemNotificationHelper -import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import org.slf4j.LoggerFactory -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds - - -/** - * This foreground service starts a [PhoenixBusiness] upon receiving FCM payment messages from the LSP. Being a foreground - * service, it needs to display a foreground notification visible by the user. - * - * This service allows Phoenix to receive payments (or settle pending payments) even when it's closed or in the background. - */ -class PaymentsForegroundService : Service() { - - private val log = LoggerFactory.getLogger(this::class.java) - private val serviceScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) - private lateinit var notificationManager: NotificationManagerCompat - - override fun onCreate() { - super.onCreate() - log.debug("creating node service...") - notificationManager = NotificationManagerCompat.from(this) - log.debug("service created") - } - - override fun onBind(intent: Intent?) = null - - private val shutdownHandler = Handler(Looper.getMainLooper()) - private val shutdownRunnable: Runnable = Runnable { - log.info("reached scheduled shutdown while headless") - stopForeground(STOP_FOREGROUND_REMOVE) - BusinessManager.stopAllHeadlessBusinesses() - stopSelf() - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - super.onStartCommand(intent, flags, startId) - log.info("start service from intent [ intent=$intent, flag=$flags, startId=$startId ]") - - val reason = intent?.getStringExtra(EXTRA_REASON) - val walletId = intent?.getStringExtra(EXTRA_NODE_ID_HASH)?.let { WalletId(it) } - - val businessMap = BusinessManager.businessFlow.value - - // the notification for the foreground service depends on whether the business is started or not. - val shouldWeWaitLong: Boolean = when { - - walletId != null && businessMap[walletId] != null -> { - log.info("active business found for wallet=$walletId, ignoring background message (reason=$reason)") - businessMap[walletId]?.business?.let { - if (it.connectionsManager.connections.value.peer !is Connection.ESTABLISHED) { - it.appConnectionsDaemon?.forceReconnect(AppConnectionsDaemon.ControlTarget.Peer) - } - } - false - } - - walletId == null -> { - log.info("no wallet_id provided, ignoring background message (reason=$reason)") - false - } - - else -> { - when (val result = SeedManager.loadAndDecrypt( - phoenixGlobal = PhoenixGlobal( - ctx = PlatformContext( - applicationContext - ) - ) - )) { - is DecryptSeedResult.Failure.SeedFileNotFound -> { - log.info("seed not found, ignoring background message (reason=$reason)") - false - } - is DecryptSeedResult.Failure -> { - log.info("unable to read seed, ignoring background message (reason=$reason)") - serviceScope.launch { - val walletMetadataMap = (application as MantraApplication).globalPrefs.getAvailableWalletsMeta.first() - val metadata = walletMetadataMap.getByWalletIdOrDefault(walletId) - when (reason) { - "IncomingPayment" -> SystemNotificationHelper.notifyPaymentMissedAppUnavailable(applicationContext, metadata) - "PendingSettlement" -> SystemNotificationHelper.notifyPendingSettlement(applicationContext, metadata) - else -> Unit - } - } - false - } - is DecryptSeedResult.Success -> { - val userWallets = result.userWalletsMap - val wallet = userWallets[walletId] - if (wallet == null) { - log.info("seed not found for node_id=$walletId, ignoring background message (reason=$reason)") - false - } else { - serviceScope.launch(Dispatchers.Default) { - when (val res = BusinessManager.startNewBusiness(wallet.words, isHeadless = true)) { - is StartBusinessResult.Failure -> { - log.error("error when starting wallet=$walletId... from foreground service: $res") - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } - is StartBusinessResult.Success -> Unit - } - } - true - } - } - } - } - } - - // show a notification -- this is mandatory! - SystemNotificationHelper.notifyRunningHeadless(applicationContext) -// startForeground(notif) - - // service will automatically shutdown -- delay is short if the message is ignored - shutdownHandler.removeCallbacksAndMessages(null) - shutdownHandler.postDelayed(shutdownRunnable, if (shouldWeWaitLong) 2.minutes.inWholeMilliseconds else 5.seconds.inWholeMilliseconds) - - return START_NOT_STICKY - } - -// private fun startForeground(notif: Notification) { -// if (Build.VERSION.SDK_INT >= 34) { -// ServiceCompat.startForeground(this, SystemNotificationHelper.HEADLESS_NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE) -// } else { -// startForeground(SystemNotificationHelper.HEADLESS_NOTIF_ID, notif) -// } -// } - - override fun onDestroy() { - super.onDestroy() - log.info("foreground service destroyed") - } - - companion object { - const val EXTRA_REASON = "${BuildConfig.APPLICATION_ID}.FCM_MESSAGE.REASON" - const val EXTRA_NODE_ID_HASH = "${BuildConfig.APPLICATION_ID}.FCM_MESSAGE.NODE_ID_HASH" - } -} \ 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 deleted file mode 100644 index 296d956a..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/data/ElectrumServers.android.kt +++ /dev/null @@ -1,6 +0,0 @@ -package fr.acinq.phoenix.data - -import fr.acinq.lightning.io.TcpSocket -import fr.acinq.lightning.utils.ServerAddress - -actual fun platformElectrumRegtestConf(): ServerAddress = ServerAddress(host = "10.0.2.2", port = 51002, tls = TcpSocket.TLS.DISABLED) \ 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 deleted file mode 100644 index 040f4d98..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/db/DbFactory.android.kt +++ /dev/null @@ -1,52 +0,0 @@ -package fr.acinq.phoenix.db - -import androidx.sqlite.db.SupportSQLiteDatabase -import app.cash.sqldelight.db.SqlDriver -import app.cash.sqldelight.driver.android.AndroidSqliteDriver -import fr.acinq.phoenix.db.migrations.v10.AfterVersion10 -import fr.acinq.phoenix.db.migrations.v11.AfterVersion11 -import fr.acinq.phoenix.db.sqldelight.AppDatabase -import fr.acinq.phoenix.db.sqldelight.ChannelsDatabase -import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase -import fr.acinq.phoenix.utils.PlatformContext - -actual fun createChannelsDbDriver(ctx: PlatformContext, fileName: String): SqlDriver { - return AndroidSqliteDriver( - schema = ChannelsDatabase.Schema, - context = ctx.applicationContext, - name = fileName, - callback = object : AndroidSqliteDriver.Callback(schema = ChannelsDatabase.Schema) { - override fun onConfigure(db: SupportSQLiteDatabase) { - super.onConfigure(db) - db.setForeignKeyConstraintsEnabled(true) - } - } - ) -} - -actual fun createPaymentsDbDriver(ctx: PlatformContext, fileName: String, onError: (String) -> Unit): SqlDriver { - return AndroidSqliteDriver( - schema = PaymentsDatabase.Schema, - context = ctx.applicationContext, - name = fileName, - callback = object : AndroidSqliteDriver.Callback( - schema = PaymentsDatabase.Schema, - AfterVersion10(onError), - AfterVersion11(onError), - ) { - override fun onConfigure(db: SupportSQLiteDatabase) { - super.onConfigure(db) - db.setForeignKeyConstraintsEnabled(true) - } - } - ) -} - -actual fun createAppDbDriver(ctx: PlatformContext): SqlDriver { - return AndroidSqliteDriver(AppDatabase.Schema, ctx.applicationContext, "appdb.sqlite", callback = object : AndroidSqliteDriver.Callback(schema = AppDatabase.Schema) { - override fun onConfigure(db: SupportSQLiteDatabase) { - super.onConfigure(db) - db.setForeignKeyConstraintsEnabled(true) - } - }) -} \ 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 deleted file mode 100644 index fc680b07..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/db/DbHooks.android.kt +++ /dev/null @@ -1,16 +0,0 @@ -package fr.acinq.phoenix.db - -import fr.acinq.lightning.utils.UUID -import fr.acinq.phoenix.db.payments.CloudKitInterface -import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase - -actual fun didSaveWalletPayment(id: UUID, database: PaymentsDatabase) {} -actual fun didDeleteWalletPayment(id: UUID, database: PaymentsDatabase) {} -actual fun didUpdateWalletPaymentMetadata(id: UUID, database: PaymentsDatabase) {} - -actual fun didSaveContact(contactId: UUID, database: PaymentsDatabase) {} -actual fun didDeleteContact(contactId: UUID, database: PaymentsDatabase) {} - -actual fun makeCloudKitDb(appDb: SqliteAppDb, paymentsDb: SqlitePaymentsDb): CloudKitInterface? { - return null -} \ 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 deleted file mode 100644 index 3f8d3350..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/managers/DataStoreManager.android.kt +++ /dev/null @@ -1,14 +0,0 @@ -package fr.acinq.phoenix.managers - -import fr.acinq.phoenix.utils.PlatformContext -import okio.Path -import okio.Path.Companion.toOkioPath - -actual fun computePreferencePath( - platformContext: PlatformContext, - dataStoreFileName: String, -): Path { - return platformContext.applicationContext.filesDir.resolve( - relative = dataStoreFileName - ).toOkioPath() -} \ 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 deleted file mode 100644 index ab2bdfc7..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/managers/global/NetworkMonitor.android.kt +++ /dev/null @@ -1,90 +0,0 @@ -package fr.acinq.phoenix.managers.global - -import android.content.Context -import android.net.ConnectivityManager -import android.net.LinkProperties -import android.net.Network -import android.net.NetworkCapabilities -import android.os.Build -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.lightning.logging.debug -import fr.acinq.lightning.logging.info -import fr.acinq.phoenix.utils.PlatformContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import java.util.concurrent.atomic.AtomicBoolean - -actual class NetworkMonitor actual constructor(loggerFactory: LoggerFactory, val ctx: PlatformContext) : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) { - - val logger = loggerFactory.newLogger(this::class) - - private val isCallbackRegistered = AtomicBoolean(false) - - private val _networkState = MutableStateFlow(NetworkState.NotAvailable) - actual val networkState: StateFlow = _networkState - - 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 deleted file mode 100644 index 3416c197..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/security/KeyStoreFunctions.android.kt +++ /dev/null @@ -1,23 +0,0 @@ -package fr.acinq.phoenix.security - -import co.touchlab.kermit.Logger -import fr.acinq.phoenix.utils.extensions.tryWith -import java.security.GeneralSecurityException - -actual fun keyStoreDecryption( - keyName: String, - iv: ByteArray, - ciphertext: ByteArray -): ByteArray { - Logger.withTag("KeyStoreFunctions").i("IV length: ${iv.size}") - return KeystoreHelper.getDecryptionCipher(keyName, iv).doFinal(ciphertext) -} - -actual fun keyStoreEncryption(keyName: String, plainText: ByteArray): Pair = 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 deleted file mode 100644 index a5ef2abf..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/security/KeystoreHelper.kt +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright 2020 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.security - -import android.os.Build -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties -import co.touchlab.kermit.Logger -import java.security.KeyStore -import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey -import javax.crypto.spec.IvParameterSpec - -object KeystoreHelper { - private val log = Logger.withTag("KeystoreHelper") - - private val ENC_ALGO = KeyProperties.KEY_ALGORITHM_AES - private val ENC_BLOCK_MODE = KeyProperties.BLOCK_MODE_CBC - private val ENC_PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7 - - private val keyStore by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { - KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - } - - private fun getOrCreateKeyNoAuthRequired(): SecretKey { - keyStore.getKey(KeyStoreNames.KEY_NO_AUTH, null)?.let { return it as SecretKey } - val spec = KeyGenParameterSpec.Builder(KeyStoreNames.KEY_NO_AUTH, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT).apply { - setBlockModes(ENC_BLOCK_MODE) - setEncryptionPaddings(ENC_PADDING) - setRandomizedEncryptionRequired(true) - setKeySize(256) - setUserAuthenticationRequired(false) - setInvalidatedByBiometricEnrollment(false) - } - return generateKeyWithSpec(spec) - } - - /** Generate key from key gen specs. If possible, store the key in strongbox. */ - private fun generateKeyWithSpec(spec: KeyGenParameterSpec.Builder): SecretKey { - val keygen = KeyGenerator.getInstance(ENC_ALGO, keyStore.provider) - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - // try to use strongbox on Android 9+, which is only supported by a few devices - try { - spec.setIsStrongBoxBacked(true) - keygen.init(spec.build()) - keygen.generateKey() - } catch (e: Exception) { - log.w("failed to generate key with strongbox enabled: ${e.javaClass.simpleName}: ${e.localizedMessage}, trying again without strongbox") - spec.setIsStrongBoxBacked(false) - keygen.init(spec.build()) - keygen.generateKey() - } - } else { - keygen.init(spec.build()) - keygen.generateKey() - } - } - - private fun getKeyForName(keyName: String): SecretKey = when (keyName) { - KeyStoreNames.KEY_NO_AUTH -> getOrCreateKeyNoAuthRequired() - KeyStoreNames.KEY_FOR_PINCODE_V1 -> getOrCreateKeyNoAuthRequired() - else -> throw IllegalArgumentException("unhandled key=$keyName") - } - - /** - * Get the encryption cipher for the key. If it fails once, delete the key, and try again. This should only be used by the seed fallback to check if the - * keystore is accessible. This seems to (rarely) happen after an OS update that fails to upgrade the key (raising a `upgrade_keyblob_if_required_with` - * error), and might be related to the secure element option? - */ - fun checkEncryptionCipherOrReset(keyName: String) = when (keyName) { - KeyStoreNames.KEY_NO_AUTH -> { - try { - getEncryptionCipher(keyName) - } catch (e: Exception) { - log.e("could not get encryption cipher: ${e.localizedMessage}") - try { - log.e("deleting key=$keyName from keystore") - keyStore.deleteEntry(keyName) - getEncryptionCipher(keyName) - } catch (e: Exception) { - log.e("cannot delete $keyName entry from keystore: ${e.localizedMessage}") - throw e - } - } - } - - else -> { - throw IllegalArgumentException("unhandled key_name=$keyName") - } - } - - /** Get encryption Cipher for given key. */ - internal fun getEncryptionCipher(keyName: String): Cipher = Cipher.getInstance("$ENC_ALGO/$ENC_BLOCK_MODE/$ENC_PADDING").apply { - init(Cipher.ENCRYPT_MODE, getKeyForName(keyName), parameters) - } - - /** Get decryption Cipher for given key. */ - internal fun getDecryptionCipher(keyName: String, iv: ByteArray): Cipher = Cipher.getInstance("$ENC_ALGO/$ENC_BLOCK_MODE/$ENC_PADDING").apply { - init(Cipher.DECRYPT_MODE, getKeyForName(keyName), IvParameterSpec(iv)) - } -} diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/ContactsPhotoHelper.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/ContactsPhotoHelper.kt deleted file mode 100644 index 734d7de0..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/ContactsPhotoHelper.kt +++ /dev/null @@ -1,91 +0,0 @@ -package fr.acinq.phoenix.utils - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Matrix -import android.media.ExifInterface -import android.net.Uri -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap -import androidx.core.content.FileProvider -import press.mantra.android.BuildConfig -import fr.acinq.lightning.utils.currentTimestampMillis -import org.slf4j.LoggerFactory -import java.io.File -import java.io.FileOutputStream - - -object ContactsPhotoHelper { - - private val log = LoggerFactory.getLogger(this::class.java) - - fun contactsDir(context: Context) = File(context.filesDir, "contacts") - - /** Creates a temporary file where the camera activity will store the picture in. File is deleted on app exit. */ - fun createTempContactPictureUri( - context: Context, - ): Uri? { - val cacheContactsDir = File(context.cacheDir, "contacts") // using cacheDir ! - if (!cacheContactsDir.exists()) cacheContactsDir.mkdir() - return try { - val tempFile = File.createTempFile("contact_", ".png", cacheContactsDir) - tempFile.deleteOnExit() - FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", tempFile) - } catch (e: Exception) { - log.error("failed to write temporary file for contact: {}", e.localizedMessage) - null - } - } - - /** Creates the final picture from the temporary picture URI returned by the camera activity. The picture is resized and compressed. Returns the file name (in filesDir/contacts). */ - fun createPermaContactPicture( - context: Context, - tempFileUri: Uri, - ): String? { - val contactsDir = contactsDir(context) - if (!contactsDir.exists()) contactsDir.mkdir() - - return try { - val orientation = context.contentResolver.openInputStream(tempFileUri)?.use { - ExifInterface(it).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED) - } ?: ExifInterface.ORIENTATION_UNDEFINED - val bitmap = context.contentResolver.openInputStream(tempFileUri)?.use { - BitmapFactory.decodeStream(it) - } ?: return null - val scale = (480f / bitmap.width.coerceAtLeast(bitmap.height)).coerceAtMost(1f) - val matrix = Matrix().apply { - postScale(scale, scale) - when (orientation) { - ExifInterface.ORIENTATION_ROTATE_90 -> postRotate(90f) - ExifInterface.ORIENTATION_ROTATE_180 -> postRotate(180f) - ExifInterface.ORIENTATION_ROTATE_270 -> postRotate(270f) - } - } - val scaledBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, false) - val photoFile = File(contactsDir, "contact_${currentTimestampMillis()}.jpg") - FileOutputStream(photoFile).use { scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 50, it) } - photoFile.name - } catch (e: Exception) { - log.error("failed to write contact photo to disk: {}", e.localizedMessage) - null - } - } - - fun getPhotoForFile( - context: Context, - fileName: String - ): ImageBitmap? { - val contactsDir = contactsDir(context) - if (!contactsDir.exists() || !contactsDir.canRead()) return null - - return try { - val photoFile = File(contactsDir, fileName) - val content = photoFile.readBytes() - BitmapFactory.decodeByteArray(content, 0, content.size).asImageBitmap() - } catch (e: Exception) { - log.info("could not read contact photo=$fileName: ", e.localizedMessage) - null - } - } -} diff --git a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/Logging.kt b/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/Logging.kt deleted file mode 100644 index 92338d3f..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/Logging.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2019 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils - -import android.content.Context -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream - -object Logging { - - private const val LOGS_DIR = "logs" - private const val CURRENT_LOG_FILE = "phoenix.log" - private const val ARCHIVED_LOG_FILE = "phoenix.archive-%i.log" - - fun exportLogFile(context: Context): File { - val export = File(File(context.filesDir, LOGS_DIR), "phoenix_export.log") - val exportOutputStream = FileOutputStream(export) - val exportChannel = exportOutputStream.channel - - // write archive-1 to export file, if available - File(File(context.filesDir, LOGS_DIR), "phoenix.archive-1.log").takeIf { - it.exists() && it.isFile && it.canRead() - }?.let { - FileInputStream(it) - }?.also { - val channel = it.channel - channel.transferTo(0, channel.size(), exportChannel) - channel.close() - }?.close() - - // write current log file to export file, if available - File(File(context.filesDir, LOGS_DIR), CURRENT_LOG_FILE).takeIf { - it.exists() && it.isFile && it.canRead() - }?.let { - FileInputStream(it) - }?.also { - val channel = it.channel - channel.transferTo(0, channel.size(), exportChannel) - channel.close() - }?.close() - - exportChannel.close() - exportOutputStream.close() - return export - } - -} 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 deleted file mode 100644 index a5f224ab..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/PlatformContext.android.kt +++ /dev/null @@ -1,16 +0,0 @@ -package fr.acinq.phoenix.utils - -import android.content.Context - -actual class PlatformContext(val applicationContext: Context) - -actual fun getApplicationFilesDirectoryPath(ctx: PlatformContext): String = - ctx.applicationContext.filesDir.absolutePath - -actual fun getDatabaseFilesDirectoryPath(ctx: PlatformContext): String? = null - -actual fun getApplicationCacheDirectoryPath(ctx: PlatformContext): String = - ctx.applicationContext.cacheDir.absolutePath - -actual fun getTemporaryDirectoryPath(ctx: PlatformContext): String = - ctx.applicationContext.cacheDir.absolutePath \ 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 deleted file mode 100644 index d0dfcf10..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/SystemNotificationHelper.kt +++ /dev/null @@ -1,335 +0,0 @@ -/* - * Copyright 2021 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils - -import android.Manifest -import android.app.Notification -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.os.Build -import android.text.format.DateUtils -import androidx.core.app.ActivityCompat -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.app.TaskStackBuilder -import androidx.core.net.toUri -import press.mantra.android.BuildConfig -import press.mantra.android.MainActivity -import press.mantra.android.R -import fr.acinq.bitcoin.Satoshi -import fr.acinq.lightning.LiquidityEvents -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.utils.UUID -import fr.acinq.lightning.utils.currentTimestampMillis -import fr.acinq.phoenix.data.BitcoinUnit -import fr.acinq.phoenix.data.ExchangeRate -import fr.acinq.phoenix.data.FiatCurrency -import fr.acinq.phoenix.data.WalletId -import fr.acinq.phoenix.utils.converters.AmountFormatter.toPrettyString -import fr.acinq.phoenix.utils.converters.DateFormatter.toAbsoluteDateString -import fr.acinq.phoenix.utils.preferences.UserPrefs -import fr.acinq.phoenix.utils.preferences.UserWalletMetadata -import kotlinx.coroutines.flow.first -import org.slf4j.LoggerFactory -import java.text.DecimalFormat - -object SystemNotificationHelper { - private const val PAYMENT_FAILED_NOTIF_ID = 354319 - private const val PAYMENT_FAILED_NOTIF_CHANNEL = "${BuildConfig.APPLICATION_ID}.PAYMENT_FAILED_NOTIF" - private const val PAYMENT_RECEIVED_NOTIF_CHANNEL = "${BuildConfig.APPLICATION_ID}.PAYMENT_RECEIVED_NOTIF" - - private const val SETTLEMENT_PENDING_NOTIF_ID = 354322 - private const val SETTLEMENT_PENDING_NOTIF_CHANNEL = "${BuildConfig.APPLICATION_ID}.SETTLEMENT_PENDING_NOTIF" - - const val HEADLESS_NOTIF_ID = 354321 - const val HEADLESS_NOTIF_CHANNEL = "${BuildConfig.APPLICATION_ID}.BACKGROUND_PROCESSING" - - private const val CHANNELS_WATCHER_ALERT_ID = 354324 - private const val CHANNELS_WATCHER_ALERT_CHANNEL = "${BuildConfig.APPLICATION_ID}.CHANNELS_WATCHER" - - private const val SWAP_TIMEOUT_ID = 354325 - private const val SWAP_TIMEOUT_CHANNEL = "${BuildConfig.APPLICATION_ID}.SWAP_TIMEOUT" - - private val log = LoggerFactory.getLogger(this::class.java) - - /** If the remaining blocks count before a swap timeout is lower than this, we should mention it in the notification. */ - private const val SWAP_TIMEOUT_THRESHOLD_IN_BLOCKS = 144 * 30 * 2 // ~2 months - - fun registerNotificationChannels(context: Context) { - // notification channels (android 8+) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.getSystemService(NotificationManager::class.java)?.createNotificationChannels( - listOf( - // TODO: Setup notifications... -// NotificationChannel(HEADLESS_NOTIF_CHANNEL, context.getString(R.string.notification_headless_title), NotificationManager.IMPORTANCE_DEFAULT).apply { -// description = context.getString(R.string.notification_headless_desc) -// }, -// NotificationChannel(CHANNELS_WATCHER_ALERT_CHANNEL, context.getString(R.string.notification_channels_watcher_title), NotificationManager.IMPORTANCE_HIGH).apply { -// description = context.getString(R.string.notification_channels_watcher_desc) -// }, -// NotificationChannel(SETTLEMENT_PENDING_NOTIF_CHANNEL, context.getString(R.string.notification_pending_settlement_title), NotificationManager.IMPORTANCE_HIGH).apply { -// description = context.getString(R.string.notification_pending_settlement_desc) -// }, -// NotificationChannel(PAYMENT_RECEIVED_NOTIF_CHANNEL, context.getString(R.string.notification_received_payment_title), NotificationManager.IMPORTANCE_LOW).apply { -// description = context.getString(R.string.notification_received_payment_desc) -// }, -// NotificationChannel(PAYMENT_FAILED_NOTIF_CHANNEL, context.getString(R.string.notification_missed_payment_title), NotificationManager.IMPORTANCE_DEFAULT).apply { -// description = context.getString(R.string.notification_missed_payment_desc) -// }, -// NotificationChannel(SWAP_TIMEOUT_CHANNEL, context.getString(R.string.notification_swap_timeout_title), NotificationManager.IMPORTANCE_HIGH).apply { -// description = context.getString(R.string.notification_swap_timeout_desc) -// }, - ) - ) - } - } - - fun notifyRunningHeadless(context: Context): Notification { - return NotificationCompat.Builder(context, HEADLESS_NOTIF_CHANNEL).apply { -// TODO: setContentTitle(context.getString(Res.string.notif_headless_title_default)) - setContentTitle("Running Headless") -// TODO: setSmallIcon(R.drawable.ic_phoenix_outline) - }.build().also { - if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { - NotificationManagerCompat.from(context).notify(HEADLESS_NOTIF_ID, it) - } - } - } - - private fun notifyPaymentFailed(context: Context, walletMetadata: UserWalletMetadata, title: String, message: String, deepLink: String?): Notification { - return NotificationCompat.Builder(context, PAYMENT_FAILED_NOTIF_CHANNEL).apply { - setContentTitle(getTitleForWallet(walletMetadata, title)) - setContentText(message) - setStyle(NotificationCompat.BigTextStyle().bigText(message)) -// TODO: setSmallIcon(R.drawable.ic_phoenix_outline) - val intent = deepLink?.let { - Intent(Intent.ACTION_VIEW, it.toUri(), context, MainActivity::class.java) - } ?: Intent(context, MainActivity::class.java) - setContentIntent( - TaskStackBuilder.create(context).run { - addNextIntentWithParentStack(intent) - getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE) - } - ) - - setAutoCancel(true) - }.build().also { - if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { - NotificationManagerCompat.from(context).notify(PAYMENT_FAILED_NOTIF_ID, it) - } - } - } - - private fun getTitleForWallet(walletMetadata: UserWalletMetadata, title: String): String { - return "${walletMetadata.avatar} $title" - } - - fun notifyPaymentRejectedPolicyDisabled(context: Context, walletId: WalletId, walletMetadata: UserWalletMetadata, source: LiquidityEvents.Source, amountIncoming: MilliSatoshi, nextTimeoutRemainingBlocks: Int?): Notification { - return notifyPaymentFailed( - context = context, - title = "Payment rejected policy disabled", -// TODO: title = context.getString(if (source == LiquidityEvents.Source.OnChainWallet) R.string.notif_rejected_deposit_title else R.string.notif_rejected_payment_title, -// amountIncoming.toPrettyString(BitcoinUnit.Sat, withUnit = true)), - message = when { - source == LiquidityEvents.Source.OnChainWallet && nextTimeoutRemainingBlocks != null && nextTimeoutRemainingBlocks < SWAP_TIMEOUT_THRESHOLD_IN_BLOCKS -> { - nextTimeoutRemainingBlocks * 10 * DateUtils.MINUTE_IN_MILLIS -// TODO: context.getString(R.string.notif_rejected_policy_disabled_timeout, (currentTimestampMillis() + remainingTimeMillis).toAbsoluteDateString()) - "Rejected policy disabled timeout" - } - else -> { -// TODO: context.getString(R.string.notif_rejected_policy_disabled) - "Rejected policy disabled" - } - }, - walletMetadata = walletMetadata, - deepLink = if (source == LiquidityEvents.Source.OnChainWallet) "phoenix:swapinwallet/$walletId" else "phoenix:notifications/$walletId", - ) - } - - fun notifyPaymentRejectedOverAbsolute(context: Context, walletId: WalletId, walletMetadata: UserWalletMetadata, source: LiquidityEvents.Source, amountIncoming: MilliSatoshi, fee: MilliSatoshi, absoluteMax: Satoshi, nextTimeoutRemainingBlocks: Int?): Notification { - return notifyPaymentFailed( - context = context, - title = getTitleForWallet(walletMetadata, context.getString(if (source == LiquidityEvents.Source.OnChainWallet) R.string.notif_rejected_deposit_title else R.string.notif_rejected_payment_title, - amountIncoming.toPrettyString(BitcoinUnit.Sat, withUnit = true))), - message = when { - source == LiquidityEvents.Source.OnChainWallet && nextTimeoutRemainingBlocks != null && nextTimeoutRemainingBlocks < SWAP_TIMEOUT_THRESHOLD_IN_BLOCKS -> { - val remainingTimeMillis = nextTimeoutRemainingBlocks * 10 * DateUtils.MINUTE_IN_MILLIS - context.getString( - R.string.notif_rejected_over_absolute_timeout, fee.toPrettyString(BitcoinUnit.Sat, withUnit = true), - absoluteMax.toPrettyString(BitcoinUnit.Sat, withUnit = true), (currentTimestampMillis() + remainingTimeMillis).toAbsoluteDateString() - ) - } - else -> { - context.getString(R.string.notif_rejected_over_absolute, fee.toPrettyString(BitcoinUnit.Sat, withUnit = true), - absoluteMax.toPrettyString(BitcoinUnit.Sat, withUnit = true)) - } - }, - walletMetadata = walletMetadata, - deepLink = if (source == LiquidityEvents.Source.OnChainWallet) "phoenix:swapinwallet/$walletId" else "phoenix:notifications/$walletId", - ) - } - - fun notifyPaymentRejectedOverRelative(context: Context, walletId: WalletId, walletMetadata: UserWalletMetadata, source: LiquidityEvents.Source, amountIncoming: MilliSatoshi, fee: MilliSatoshi, percentMax: Int, nextTimeoutRemainingBlocks: Int?): Notification { - return notifyPaymentFailed( - context = context, - title = context.getString(if (source == LiquidityEvents.Source.OnChainWallet) R.string.notif_rejected_deposit_title else R.string.notif_rejected_payment_title, - amountIncoming.toPrettyString(BitcoinUnit.Sat, withUnit = true)), - message = when { - source == LiquidityEvents.Source.OnChainWallet && nextTimeoutRemainingBlocks != null && nextTimeoutRemainingBlocks < SWAP_TIMEOUT_THRESHOLD_IN_BLOCKS -> { - val remainingTimeMillis = nextTimeoutRemainingBlocks * 10 * DateUtils.MINUTE_IN_MILLIS - context.getString(R.string.notif_rejected_over_relative_timeout, fee.toPrettyString(BitcoinUnit.Sat, withUnit = true), - DecimalFormat("0.##").format(percentMax.toDouble() / 100), (currentTimestampMillis() + remainingTimeMillis).toAbsoluteDateString() - ) - } - else -> { - context.getString(R.string.notif_rejected_over_relative, fee.toPrettyString(BitcoinUnit.Sat, withUnit = true), - DecimalFormat("0.##").format(percentMax.toDouble() / 100)) - } - }, - walletMetadata = walletMetadata, - deepLink = if (source == LiquidityEvents.Source.OnChainWallet) "phoenix:swapinwallet/$walletId" else "phoenix:notifications/$walletId", - ) - } - - fun notifyPaymentRejectedAmountTooLow(context: Context, walletId: WalletId, walletMetadata: UserWalletMetadata, source: LiquidityEvents.Source, amountIncoming: MilliSatoshi): Notification { - return notifyPaymentFailed( - context = context, - title = context.getString(if (source == LiquidityEvents.Source.OnChainWallet) R.string.notif_rejected_deposit_title else R.string.notif_rejected_payment_title, - amountIncoming.toPrettyString(BitcoinUnit.Sat, withUnit = true)), - message = context.getString(R.string.notif_rejected_amount_too_low), - walletMetadata = walletMetadata, - deepLink = if (source == LiquidityEvents.Source.OnChainWallet) "phoenix:swapinwallet/$walletId" else "phoenix:notifications/$walletId", - ) - } - - fun notifyPaymentRejectedFundingError(context: Context, walletId: WalletId, walletMetadata: UserWalletMetadata, source: LiquidityEvents.Source, amountIncoming: MilliSatoshi): Notification { - return notifyPaymentFailed( - context = context, - title = context.getString(if (source == LiquidityEvents.Source.OnChainWallet) R.string.notif_rejected_deposit_title else R.string.notif_rejected_payment_title, - amountIncoming.toPrettyString(BitcoinUnit.Sat, withUnit = true)), - message = context.getString(R.string.notif_rejected_generic_error), - walletMetadata = walletMetadata, - deepLink = if (source == LiquidityEvents.Source.OnChainWallet) "phoenix:swapinwallet/$walletId" else "phoenix:notifications/$walletId", - ) - } - - fun notifyPaymentMissedAppUnavailable(context: Context, walletMetadata: UserWalletMetadata): Notification { - return notifyPaymentFailed( - context = context, - title = context.getString(R.string.notif_missed_title), - message = context.getString(R.string.notif_missed_unavailable), - walletMetadata = walletMetadata, - deepLink = null - ) - } - - fun notifyPendingSettlement(context: Context, walletMetadata: UserWalletMetadata): Notification { - return NotificationCompat.Builder(context, SETTLEMENT_PENDING_NOTIF_CHANNEL).apply { - setContentTitle(getTitleForWallet(walletMetadata, context.getString(R.string.notif_pending_settlement_title))) - setContentText(context.getString(R.string.notif_pending_settlement_message)) - setStyle(NotificationCompat.BigTextStyle().bigText(context.getString(R.string.notif_pending_settlement_message))) - setSmallIcon(R.drawable.ic_launcher_foreground) - setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE)) - setAutoCancel(true) - }.build().also { - if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { - NotificationManagerCompat.from(context).notify(SETTLEMENT_PENDING_NOTIF_ID, it) - } - } - } - - fun notifyInFlightHtlc(context: Context, walletMetadata: UserWalletMetadata): Notification { - return NotificationCompat.Builder(context, SETTLEMENT_PENDING_NOTIF_CHANNEL).apply { - setContentTitle(getTitleForWallet(walletMetadata, context.getString(R.string.notif_inflight_payment_title))) - setContentText(context.getString(R.string.notif_inflight_payment_message)) - setStyle(NotificationCompat.BigTextStyle().bigText(context.getString(R.string.notif_inflight_payment_message))) - setSmallIcon(R.drawable.ic_launcher_foreground) - setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE)) - setAutoCancel(true) - }.build().also { - if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { - NotificationManagerCompat.from(context).notify(SETTLEMENT_PENDING_NOTIF_ID, it) - } - } - } - - suspend fun notifyPaymentsReceived( - context: Context, - userPrefs: UserPrefs, - walletId: WalletId, - userWalletMetadata: UserWalletMetadata, - paymentId: UUID, - paymentAmount: MilliSatoshi, - rates: List, - ): 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 deleted file mode 100644 index c6218f07..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/converters/DateFormatter.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils.converters - -import android.annotation.SuppressLint -import android.text.format.DateUtils -import androidx.compose.runtime.Composable -import java.text.DateFormat -import java.text.SimpleDateFormat -import java.util.Date -import kotlin.math.abs - -object DateFormatter { - - /** Converts this millis timestamp into a relative string date. */ - @Composable - fun Long.toRelativeDateString(): String { - val now = System.currentTimeMillis() - val delay: Long = this - now - return if (abs(delay) < 60 * 1000L) { // less than 1 minute ago -// TODO: stringResource(id = R.string.utils_date_just_now) - "Just now" - } else { - DateUtils.getRelativeTimeSpanString(this, now, delay).toString() - } - } - - /** Converts this millis timestamp into a pretty, absolute string date time using the locale format. */ - fun Long.toAbsoluteDateTimeString(): String = DateFormat.getDateTimeInstance().format(Date(this)) - - /** Converts this millis timestamp into a pretty, absolute string date using the locale format. */ - fun Long.toAbsoluteDateString(): String = DateFormat.getDateInstance().format(Date(this)) - - /** Converts this millis timestamp into an year-month-day string. */ - @SuppressLint("SimpleDateFormat") - fun Long.toBasicAbsoluteDateString(): String = SimpleDateFormat("yyyy-MM-dd").format(Date(this)) - - /** Converts this millis timestamp into an year-month-day string. */ - @SuppressLint("SimpleDateFormat") - fun Long.toBasicAbsoluteDateTimeString(): String = SimpleDateFormat("yyyyMMdd-HHmmss").format(Date(this)) -} 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 deleted file mode 100644 index d21867fe..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/extensions/AndroidContextExtensions.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils.extensions - -import android.content.Context -import android.content.ContextWrapper -import press.mantra.android.MainActivity - -fun Context.findActivitySafe(): MainActivity? { - var context = this - while (context is ContextWrapper) { - if (context is MainActivity) return context - context = context.baseContext - } - return null -} - -fun Context.findActivity(): MainActivity { - var context = this - while (context is ContextWrapper) { - if (context is MainActivity) return context - context = context.baseContext - } - throw IllegalStateException("not in the context of the main Phoenix activity") -} \ 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 deleted file mode 100644 index e5580571..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.android.kt +++ /dev/null @@ -1,35 +0,0 @@ -package fr.acinq.phoenix.utils.extensions - -import co.touchlab.kermit.Logger -import fr.acinq.phoenix.data.DecryptSeedResult -import kotlinx.serialization.SerializationException -import java.security.KeyStoreException - -actual inline fun gracefulSingleSeedDecryption(action: () -> DecryptSeedResult): DecryptSeedResult = try { - action.invoke() -} catch (e: Exception) { - return when (e) { - is KeyStoreException -> DecryptSeedResult.Failure.KeyStoreFailure(e) - else -> DecryptSeedResult.Failure.DecryptionError(e) - } -} - -actual inline fun gracefulMultiSeedDecryption(action: () -> DecryptSeedResult): DecryptSeedResult = try { - action.invoke() -} catch (e: Exception) { - val log = Logger.withTag("gracefulMultiSeedDecryption") - return when (e) { - is SerializationException, is IllegalArgumentException -> { - log.e("failed to decrypt [V2.MultipleSeed]: ${e.javaClass.simpleName}") - DecryptSeedResult.Failure.SerializationError - } - is KeyStoreException -> { - log.e("failed to decrypt [V2.MultipleSeed]: ", e) - DecryptSeedResult.Failure.KeyStoreFailure(e) - } - else -> { - log.e("failed to decrypt [V2.MultipleSeed]: ", e) - DecryptSeedResult.Failure.DecryptionError(e) - } - } -} \ 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 deleted file mode 100644 index 7773c1e3..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils.extensions - -import kotlin.contracts.ExperimentalContracts -import kotlin.contracts.InvocationKind -import kotlin.contracts.contract - -/** - * Utility method rebinding any exceptions thrown by a method into another exception, using the origin exception as the root cause. - * Helps with pattern matching. - */ -inline fun 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 deleted file mode 100644 index 026cee64..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/logger/LoggerConfig.android.kt +++ /dev/null @@ -1,31 +0,0 @@ -package fr.acinq.phoenix.utils.logger - -import co.touchlab.kermit.LogWriter -import co.touchlab.kermit.Severity -import co.touchlab.kermit.Severity.Assert -import co.touchlab.kermit.Severity.Debug -import co.touchlab.kermit.Severity.Info -import co.touchlab.kermit.Severity.Verbose -import co.touchlab.kermit.Severity.Warn -import fr.acinq.phoenix.utils.PlatformContext -import org.slf4j.LoggerFactory - -/** - * Use SLF4J writer on Android. Note that writing logs to Logcat is already done in - * phoenix-android SLF4J configuration. - */ -actual fun phoenixLogWriters(ctx: PlatformContext): List = 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 deleted file mode 100644 index 698f388f..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/ApduCommands.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils.nfc - -import fr.acinq.bitcoin.ByteVector - -object ApduCommands { - - private const val SELECT_FILE = "00A4000C02" - - // structure is CLA-INS-P1-P2 -- NDEF AID -- LE field - // This AID is also the one defined in res/xml/apduservice.xml - val SELECT_AID = ByteVector("00a4040007" + "d2760000850101") - // LE field is optional, we should accept the command if LE is set - val SELECT_AID_LE = SELECT_AID.concat(0x00) - - // file id of the CC file is 0xe103 - val SELECT_CC_FILE = ByteVector(SELECT_FILE + "E103") - // file id of the ndef file is e104 - val SELECT_NDEF_FILE = ByteVector(SELECT_FILE + "E104") - - // file identifier = ndef - // maximum ndef file size = 1024b - // read/write access 00 - val READ_CC = ByteVector("00B000000F") - val READ_CC_RESPONSE = ByteVector("000F20003B00340406" + "E104" + "4000" + "0000" + "9000").toByteArray() - - val READ_NDEF_BINARY_LENGTH = ByteVector("00B0000002") - val READ_NDEF_BINARY = ByteVector("00B0").toByteArray() - - val A_OKAY = ByteVector("9000").toByteArray() - val A_ERROR = ByteVector("6A82").toByteArray() - val NDEF_FILE = ByteVector("E104").toByteArray() -} \ 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 deleted file mode 100644 index 0b2b2f88..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/NfcHelper.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils.nfc - -import android.nfc.NdefRecord - -object NfcHelper { - - fun createTextRecord(text: String, id: ByteArray): NdefRecord { - val languageBytes = "en".toByteArray(Charsets.US_ASCII) - val langLength = languageBytes.size and 0x3F - val textBytes = text.toByteArray(Charsets.UTF_8) - - val payload = byteArrayOf(langLength.toByte()) + languageBytes.copyOfRange(0, langLength) + textBytes - - return NdefRecord(NdefRecord.TNF_WELL_KNOWN, NdefRecord.RTD_TEXT, id, payload) - } - - fun fillByteArrayToFixedDimension(array: ByteArray, fixedSize: Int): ByteArray = - if (array.size >= fixedSize) { - array.copyOfRange(0, fixedSize) - } else { - fillByteArrayToFixedDimension(byteArrayOf(0x00) + array, fixedSize) - } - - fun intToByteArray(value: Int): ByteArray { - if (value == 0) return byteArrayOf(0) - val bytes = mutableListOf() - 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 deleted file mode 100644 index 28a329b9..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/NfcParser.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils.nfc - -import android.nfc.NdefRecord -import org.slf4j.LoggerFactory - -object NdefParser { - private val log = LoggerFactory.getLogger(this::class.java) - - @OptIn(ExperimentalStdlibApi::class) - fun parseNdefRecord(record: NdefRecord): String? { - val tnf = record.tnf - val type = record.type - val payload = record.payload - log.info("parsing ndef record tnf=$tnf type=${type.toHexString()} payload=${payload.decodeToString()}") - - return try { - when (tnf) { - NdefRecord.TNF_WELL_KNOWN -> { - if (type.contentEquals(NdefRecord.RTD_TEXT)) { - parseTextRecord(payload) - } else if (type.contentEquals(NdefRecord.RTD_URI)) { - parseUriRecord(payload) - } else { - log.debug("unhandled well-known record with type={}", type) - null - } - } - NdefRecord.TNF_ABSOLUTE_URI -> { - parseAbsoluteUriRecord(payload) - } - else -> { - log.debug("unhandled tnf={}", tnf) - null - } - } - } catch (e: Exception) { - log.warn("failed to parse ndef record: ${e.message}") - null - } - } - - private fun parseTextRecord(payload: ByteArray): String { - val textEncoding = if (((payload[0].toInt() and 0x80) == 0)) "UTF-8" else "UTF-16" - val languageCodeLength = payload[0].toInt() and 0x3F - return String(payload, languageCodeLength + 1, payload.size - languageCodeLength - 1, charset(textEncoding)) - } - - private fun parseUriRecord(payload: ByteArray): String { - // URI records have a prefix byte followed by the URI - return String(payload, 1, payload.size - 1, Charsets.UTF_8) - } - - private fun parseAbsoluteUriRecord(payload: ByteArray): String { - return String(payload, Charsets.UTF_8) - } -} \ 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 deleted file mode 100644 index e72e587a..00000000 --- a/composeApp/src/androidMain/kotlin/fr/acinq/phoenix/utils/nfc/NfcReaderCallback.kt +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils.nfc - -import android.nfc.NdefMessage -import android.nfc.NfcAdapter -import android.nfc.Tag -import android.nfc.tech.IsoDep -import com.machankura.compose.ui.composable.widgets.nfc.NfcState -import com.machankura.compose.ui.composable.widgets.nfc.NfcStateRepository -import fr.acinq.lightning.utils.toByteVector -import fr.acinq.phoenix.android.services.HceService -import org.slf4j.LoggerFactory - -/** - * This class retrieves the first NDEF message in an NFC tag. - * - * Sends a sequence of APDU commands to the tag, mirroring what's done in [HceService]. - */ -class NfcReaderCallback(val onFoundData: (String) -> Unit) : NfcAdapter.ReaderCallback { - private val log = LoggerFactory.getLogger(this::class.java) - - @OptIn(ExperimentalStdlibApi::class) - override fun onTagDiscovered(tag: Tag?) { - log.info("discovered tag=$tag") - val isoDep = IsoDep.get(tag) - if (isoDep == null) { - log.info("aborting tag discovery: tag does not support iso-dep") - return - } - - if (!NfcStateRepository.isReading()) { - log.info("aborting tag discovery: nfc_state=${NfcStateRepository.state.value}") - isoDep.close() - return - } - - try { - log.debug("connecting to tag={}", tag) - isoDep.connect() - log.info("nfc reader connected, starting communication...") - - val selectCommand = ApduCommands.SELECT_AID - log.debug("SEND select aid: ${selectCommand.toByteArray().toHexString()}") - val responseSelect = isoDep.transceive(selectCommand.toByteArray()) - log.debug("RECV select aid: ${responseSelect.toByteVector().toHex()}") - if (!responseSelect.contentEquals(ApduCommands.A_OKAY)) { - throw IllegalArgumentException("invalid response for select aid: ${responseSelect.toHexString()}") - } - - log.debug("SEND select cc: ${ApduCommands.SELECT_CC_FILE.toByteArray().toHexString()}") - val selectCC = isoDep.transceive(ApduCommands.SELECT_CC_FILE.toByteArray()) - log.debug("RECV select cc: ${selectCC.toByteVector().toHex()}") - if (!selectCC.contentEquals(ApduCommands.A_OKAY)) { - throw IllegalArgumentException("invalid response for select cc: ${selectCC.toHexString()}") - } - - log.debug("SEND read cc: ${ApduCommands.READ_CC.toByteArray().toHexString()}") - val responseReadCC = isoDep.transceive(ApduCommands.READ_CC.toByteArray()) - log.debug("RECV read cc: ${responseReadCC.toByteVector().toHex()}") - if (!responseReadCC.takeLast(2).toByteArray().contentEquals(ApduCommands.A_OKAY)) { - throw IllegalArgumentException("invalid response for read cc: ${responseReadCC.toByteVector().toHex()}") - } - - log.debug("SEND select ndef: ${ApduCommands.SELECT_NDEF_FILE.toByteArray().toHexString()}") - val selectNdefFile = isoDep.transceive(ApduCommands.SELECT_NDEF_FILE.toByteArray()) - log.debug("RECV select ndef: ${selectNdefFile.toByteVector().toHex()}") - if (!selectNdefFile.contentEquals(ApduCommands.A_OKAY)) { - throw IllegalArgumentException("invalid response for select ndef: ${selectNdefFile.toByteVector().toHex()}") - } - - log.debug("SEND select bin len: ${ApduCommands.READ_NDEF_BINARY_LENGTH.toByteArray().toHexString()}") - val readBinaryLengthResponse = isoDep.transceive(ApduCommands.READ_NDEF_BINARY_LENGTH.toByteArray()) - log.debug("RECV select bin len: ${readBinaryLengthResponse.toByteVector().toHex()}") - val (c,s) = readBinaryLengthResponse.dropLast(2) to readBinaryLengthResponse.takeLast(2) - if (!s.toByteArray().contentEquals(ApduCommands.A_OKAY)) { - throw IllegalArgumentException("invalid response for bin length: ${s.toByteArray().toHexString()}") - } - val messageLength = c.toByteArray().toHexString().toInt(16) - log.debug("binary ndef message length=$messageLength") - - // depending on the length of the message contained in the tag, we send several commands to retrieve the - // data in chunks - - var offset = 2 - val chunkSize = 59 - var result = byteArrayOf() - - while (offset < messageLength) { - val size = if (offset + chunkSize > messageLength) { - messageLength - offset + 2 - } else { - chunkSize - } - val b = NfcHelper.intToByteArray(offset) + NfcHelper.intToByteArray(size) - val padded = ApduCommands.READ_NDEF_BINARY + NfcHelper.fillByteArrayToFixedDimension(b, 3) - log.debug("SEND read bin (o=$offset, len=$size): ${padded.toHexString()}") - val r = isoDep.transceive(padded) - log.debug("RECV read bin: ${r.toHexString()}") - if (!r.takeLast(2).toByteArray().contentEquals(ApduCommands.A_OKAY)) { - throw IllegalArgumentException("invalid response for bin read (o=$offset len=$size): ${r.toHexString()}") - } - result += r.dropLast(2) // must drop A_OKAY suffix - - offset += chunkSize - } - - val m = NdefMessage(result) - log.info("successfully read tag, found ndef_message=$m") - val d = m.records.mapNotNull { - NdefParser.parseNdefRecord(it) - } - d.firstOrNull()?.let { - onFoundData(it) - } - - } catch (e: Exception) { - log.error("failed to read tag: ", e) - } finally { - isoDep.close() - if (NfcStateRepository.isReading()) { - NfcStateRepository.updateState(NfcState.Inactive) - } - log.info("terminated nfc reader callback") - } - } -} \ 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 deleted file mode 100644 index 37ba6111..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/conf/Lsp.kt +++ /dev/null @@ -1,62 +0,0 @@ -package fr.acinq.phoenixd.conf - -import fr.acinq.bitcoin.Chain -import fr.acinq.bitcoin.PublicKey -import fr.acinq.lightning.CltvExpiryDelta -import fr.acinq.lightning.DefaultSwapInParams -import fr.acinq.lightning.InvoiceDefaultRoutingFees -import fr.acinq.lightning.NodeUri -import fr.acinq.lightning.SwapInParams -import fr.acinq.lightning.TrampolineFees -import fr.acinq.lightning.WalletParams -import fr.acinq.lightning.utils.msat -import fr.acinq.lightning.utils.sat - - -data class LSP(val walletParams: WalletParams, val swapInXpub: String) { - - companion object { - - private val trampolineFees = listOf( - TrampolineFees( - feeBase = 4.sat, - feeProportional = 4_000, - cltvExpiryDelta = CltvExpiryDelta(576) - ) - ) - - private val invoiceDefaultRoutingFees = InvoiceDefaultRoutingFees( - feeBase = 1_000.msat, - feeProportional = 100, - cltvExpiryDelta = CltvExpiryDelta(144) - ) - - private val swapInParams = SwapInParams( - minConfirmations = DefaultSwapInParams.MinConfirmations, - maxConfirmations = DefaultSwapInParams.MaxConfirmations, - refundDelay = DefaultSwapInParams.RefundDelay, - ) - - fun from(chain: Chain) = when (chain) { - is Chain.Mainnet -> LSP( - swapInXpub = "xpub69q3sDXXsLuHVbmTrhqmEqYqTTsXJKahdfawXaYuUt6muf1PbZBnvqzFcwiT8Abpc13hY8BFafakwpPbVkatg9egwiMjed1cRrPM19b2Ma7", - walletParams = WalletParams( - trampolineNode = NodeUri(PublicKey.fromHex("03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f"), "3.33.236.230", 9735), - trampolineFees, - invoiceDefaultRoutingFees, - swapInParams - ) - ) - is Chain.Testnet3 -> LSP( - swapInXpub = "tpubDAmCFB21J9ExKBRPDcVxSvGs9jtcf8U1wWWbS1xTYmnUsuUHPCoFdCnEGxLE3THSWcQE48GHJnyz8XPbYUivBMbLSMBifFd3G9KmafkM9og", - walletParams = WalletParams( - trampolineNode = NodeUri(PublicKey.fromHex("03933884aaf1d6b108397e5efe5c86bcf2d8ca8d2f700eda99db9214fc2712b134"), "13.248.222.197", 9735), - trampolineFees, - invoiceDefaultRoutingFees, - swapInParams - ) - ) - else -> error("unsupported chain $chain") - } - } -} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/conf/Seed.kt b/composeApp/src/commonMain/kotlin/fr/acinq/conf/Seed.kt deleted file mode 100644 index b1bbe430..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/conf/Seed.kt +++ /dev/null @@ -1,36 +0,0 @@ -package fr.acinq.phoenixd.conf - -import fr.acinq.bitcoin.ByteVector -import fr.acinq.bitcoin.MnemonicCode -import fr.acinq.conf.SeedSpec -import fr.acinq.lightning.Lightning.randomBytes -import fr.acinq.lightning.utils.toByteVector -import kotlinx.io.buffered -import kotlinx.io.files.Path -import kotlinx.io.files.SystemFileSystem -import kotlinx.io.readString -import kotlinx.io.writeString - -data class PhoenixSeed(val seed: ByteVector, val isNew: Boolean, val path: Path?) - -/** - * @return a pair with the seed and a boolean indicating whether the seed was newly generated - */ -fun SeedSpec.SeedPath.getOrGenerateSeed(): PhoenixSeed { - try { - val (mnemonics, isNew) = if (SystemFileSystem.exists(path)) { - val contents = SystemFileSystem.source(path).buffered().use { it.readString() } - val mnemonics = Regex("[a-z]+").findAll(contents).map { it.value }.toList() - mnemonics to false - } else { - val entropy = randomBytes(16) - val mnemonics = MnemonicCode.toMnemonics(entropy) - SystemFileSystem.sink(path).buffered().use { it.writeString(mnemonics.joinToString(" ")) } - mnemonics to true - } - MnemonicCode.validate(mnemonics) - return PhoenixSeed(seed = MnemonicCode.toSeed(mnemonics, "").toByteVector(), isNew = isNew, path = path) - } catch (t: Throwable) { - throw Throwable(t.message) - } -} \ 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 deleted file mode 100644 index b8fd60d1..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/conf/SeedSpec.kt +++ /dev/null @@ -1,9 +0,0 @@ -package fr.acinq.conf - -import fr.acinq.bitcoin.ByteVector -import kotlinx.io.files.Path - -sealed class SeedSpec { - data class Manual(val seed: ByteVector) : SeedSpec() - data class SeedPath(val path: Path) : SeedSpec() -} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/Ambients.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/Ambients.kt deleted file mode 100644 index 8b7dc2b6..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/Ambients.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2020 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.navigation.NavController -import fr.acinq.phoenix.controllers.ControllerFactory -import fr.acinq.phoenix.data.BitcoinUnit -import fr.acinq.phoenix.data.CurrencyUnit -import fr.acinq.phoenix.data.ExchangeRate -import fr.acinq.phoenix.data.FiatCurrency -import fr.acinq.phoenix.data.PreferredFiatCurrencies -import fr.acinq.phoenix.data.UserTheme -import fr.acinq.phoenix.data.WalletId -import fr.acinq.phoenix.utils.preferences.InternalPrefs -import fr.acinq.phoenix.utils.preferences.PreferredBitcoinUnits -import fr.acinq.phoenix.utils.preferences.UserPrefs - - -typealias CF = ControllerFactory - -val LocalTheme = staticCompositionLocalOf { UserTheme.SYSTEM } -val LocalWalletId = compositionLocalOf { 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 deleted file mode 100644 index f2507f1d..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/PhoenixBusiness.kt +++ /dev/null @@ -1,185 +0,0 @@ -/* - * Copyright 2022 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix - -import fr.acinq.bitcoin.Chain -import fr.acinq.lightning.blockchain.electrum.ElectrumClient -import fr.acinq.lightning.blockchain.electrum.ElectrumWatcher -import fr.acinq.lightning.io.TcpSocket -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.lightning.logging.debug -import fr.acinq.lightning.logging.info -import fr.acinq.phoenix.controllers.CloseChannelsConfigurationController -import fr.acinq.phoenix.controllers.ConfigurationController -import fr.acinq.phoenix.controllers.ContentController -import fr.acinq.phoenix.controllers.ControllerFactory -import fr.acinq.phoenix.controllers.ElectrumConfigurationController -import fr.acinq.phoenix.controllers.HomeController -import fr.acinq.phoenix.controllers.InitializationController -import fr.acinq.phoenix.controllers.ReceiveController -import fr.acinq.phoenix.controllers.RestoreWalletController -import fr.acinq.phoenix.controllers.config.AppCloseChannelsConfigurationController -import fr.acinq.phoenix.controllers.config.AppConfigurationController -import fr.acinq.phoenix.controllers.config.AppElectrumConfigurationController -import fr.acinq.phoenix.controllers.init.AppInitController -import fr.acinq.phoenix.controllers.init.AppRestoreWalletController -import fr.acinq.phoenix.controllers.main.AppContentController -import fr.acinq.phoenix.controllers.main.AppHomeController -import fr.acinq.phoenix.controllers.payments.AppReceiveController -import fr.acinq.phoenix.data.StartupParams -import fr.acinq.phoenix.managers.AppConfigurationManager -import fr.acinq.phoenix.managers.AppConnectionsDaemon -import fr.acinq.phoenix.managers.BalanceManager -import fr.acinq.phoenix.managers.ConnectionsManager -import fr.acinq.phoenix.managers.DataStoreManager -import fr.acinq.phoenix.managers.DatabaseManager -import fr.acinq.phoenix.managers.LnurlManager -import fr.acinq.phoenix.managers.NodeParamsManager -import fr.acinq.phoenix.managers.NotificationsManager -import fr.acinq.phoenix.managers.PaymentsManager -import fr.acinq.phoenix.managers.PeerManager -import fr.acinq.phoenix.managers.SendManager -import fr.acinq.phoenix.managers.WalletManager -import fr.acinq.phoenix.managers.global.CurrencyManager -import fr.acinq.phoenix.managers.global.FeerateManager -import fr.acinq.phoenix.utils.BlockchainExplorer -import fr.acinq.phoenix.utils.logger.PhoenixLoggerConfig -import kotlinx.coroutines.Job -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.cancel -import kotlin.time.Duration.Companion.seconds - -data class BusinessRunning( - val business: PhoenixBusiness, - val isHeadless: Boolean, -) - -data class BusinessMonitorJobs( - val monitorHeadlessPaymentsJob: Job?, // null if the business is not headless, which is something that can change over time - val monitorNodeEventsJob: Job, - val monitorFcmTokenJob: Job, - val monitorInFlightPaymentsJob: Job, -) - -class PhoenixBusiness( - val phoenixGlobal: PhoenixGlobal, -) { - // this logger factory will be used throughout the project (including dependencies like lightning-kmp) to - // create new [Logger] instances, and output logs to platform dependent writers. - val loggerFactory = LoggerFactory(PhoenixLoggerConfig(phoenixGlobal.ctx)) - private val logger = loggerFactory.newLogger(this::class) - - private val tcpSocketBuilder = TcpSocket.Builder() - internal val tcpSocketBuilderFactory = suspend { - tcpSocketBuilder - } - - val chain: Chain = NodeParamsManager.chain - - val electrumClient by lazy { ElectrumClient(scope = MainScope(), loggerFactory = loggerFactory, pingInterval = 30.seconds, rpcTimeout = 10.seconds) } - val electrumWatcher by lazy { ElectrumWatcher(electrumClient, MainScope(), loggerFactory) } - - var appConnectionsDaemon: AppConnectionsDaemon? = null - - val walletManager by lazy { WalletManager(chain) } - val nodeParamsManager by lazy { NodeParamsManager(this) } - val databaseManager by lazy { DatabaseManager(this) } - val dataStoreManager by lazy { DataStoreManager(this) } - val peerManager by lazy { PeerManager(this) } - val paymentsManager by lazy { PaymentsManager(this) } - val balanceManager by lazy { BalanceManager(this) } - val appConfigurationManager by lazy { AppConfigurationManager(this) } - val connectionsManager by lazy { ConnectionsManager(this) } - val lnurlManager by lazy { LnurlManager(this) } - val notificationsManager by lazy { NotificationsManager(this) } - val blockchainExplorer by lazy { BlockchainExplorer(chain) } - val sendManager by lazy { SendManager(this) } - - val feerateManager by lazy { FeerateManager(loggerFactory) } - - val currencyManager by lazy { CurrencyManager(loggerFactory, phoenixGlobal.appDb) } - - fun start(startupParams: StartupParams) { - logger.debug { "starting with params=$startupParams" } - if (appConnectionsDaemon == null) { - logger.debug { "start business" } - appConfigurationManager.setStartupParams(startupParams) - appConnectionsDaemon = AppConnectionsDaemon(this) - } - } - - /** - * Cancels the CoroutineScope of all managers, and closes all database connections. - * It's recommended that you close the network connections (electrum + peer) - * BEFORE invoking this function, to ensure a clean disconnect from the server. - */ - fun stop() { - logger.info { "stopping business" } - electrumClient.stop() - electrumWatcher.stop() - electrumWatcher.cancel() - appConnectionsDaemon?.cancel() - notificationsManager.cancel() - paymentsManager.cancel() - walletManager.cancel() - nodeParamsManager.cancel() - peerManager.peerState.value?.cancel() - peerManager.cancel() - appConfigurationManager.cancel() - databaseManager.close() - databaseManager.cancel() - lnurlManager.cancel() - logger.info { "stopped business" } - } - - // The (node_id, fcm_token) tuple only needs to be registered once. - // And after that, only if the tuple changes (e.g. different fcm_token). - suspend fun registerFcmToken(token: String?) { - logger.debug { "registering token=$token" } - peerManager.getPeer().registerFcmToken(token) - } - - private val _this = this - val controllers: ControllerFactory = object : ControllerFactory { - override fun content(): ContentController = - AppContentController(_this) - - override fun initialization(): InitializationController = - AppInitController(_this) - - override fun home(): HomeController = - AppHomeController(_this) - - override fun receive(): ReceiveController = - AppReceiveController(_this) - - override fun restoreWallet(): RestoreWalletController = - AppRestoreWalletController(_this) - - override fun configuration(): ConfigurationController = - AppConfigurationController(_this) - - override fun electrumConfiguration(): ElectrumConfigurationController = - AppElectrumConfigurationController(_this) - - override fun closeChannelsConfiguration(): CloseChannelsConfigurationController = - AppCloseChannelsConfigurationController(_this, isForceClose = false) - - override fun forceCloseChannelsConfiguration(): CloseChannelsConfigurationController = - AppCloseChannelsConfigurationController(_this, isForceClose = true) - } -} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/PhoenixGlobal.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/PhoenixGlobal.kt deleted file mode 100644 index 3aa65adb..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/PhoenixGlobal.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix - -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.lightning.logging.info -import fr.acinq.phoenix.db.SqliteAppDb -import fr.acinq.phoenix.db.createAppDbDriver -import fr.acinq.phoenix.managers.AppConnectionsDaemon -import fr.acinq.phoenix.managers.global.CurrencyManager -import fr.acinq.phoenix.managers.global.FeerateManager -import fr.acinq.phoenix.managers.global.NetworkMonitor -import fr.acinq.phoenix.managers.global.WalletContextManager -import fr.acinq.phoenix.utils.PlatformContext -import fr.acinq.phoenix.utils.logger.PhoenixLoggerConfig - - -class PhoenixGlobal(val ctx: PlatformContext) { - - val loggerFactory = LoggerFactory(PhoenixLoggerConfig(ctx)) - private val logger = loggerFactory.newLogger(this::class) - - val appDb by lazy { SqliteAppDb(createAppDbDriver(ctx)) } - val networkMonitor by lazy { NetworkMonitor(loggerFactory, ctx) } - val currencyManager by lazy { CurrencyManager(loggerFactory, appDb) } - val feerateManager by lazy { FeerateManager(loggerFactory) } - val walletContextManager by lazy { WalletContextManager(loggerFactory) } - - init { - logger.info { "init PhoenixGlobal..." } - } - - /** Called by [AppConnectionsDaemon] when internet is available. */ - internal fun enableNetworkAccess() { - feerateManager.startMonitoringFeerate() - walletContextManager.stopJobs() - currencyManager.enableNetworkAccess() - } - - /** Called by [AppConnectionsDaemon] when no connection is available. */ - internal fun disableNetworkAccess() { - feerateManager.stopMonitoringFeerate() - walletContextManager.stopJobs() - currencyManager.disableNetworkAccess() - } - -} \ 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 deleted file mode 100644 index e5883686..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/AppController.kt +++ /dev/null @@ -1,78 +0,0 @@ -package fr.acinq.phoenix.controllers - -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.lightning.logging.debug -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.consumeEach -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch - - -abstract class AppController( - 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 deleted file mode 100644 index 4e414def..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/ControllerFactory.kt +++ /dev/null @@ -1,33 +0,0 @@ -package fr.acinq.phoenix.controllers - -import fr.acinq.phoenix.controllers.config.CloseChannelsConfiguration -import fr.acinq.phoenix.controllers.config.Configuration -import fr.acinq.phoenix.controllers.config.ElectrumConfiguration -import fr.acinq.phoenix.controllers.init.Initialization -import fr.acinq.phoenix.controllers.init.RestoreWallet -import fr.acinq.phoenix.controllers.main.Content -import fr.acinq.phoenix.controllers.main.Home -import fr.acinq.phoenix.controllers.payments.Receive - -typealias ContentController = MVI.Controller -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 deleted file mode 100644 index 23ccd4a5..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/MVI.kt +++ /dev/null @@ -1,32 +0,0 @@ -package fr.acinq.phoenix.controllers - -object MVI { - - abstract class Data { - override fun toString(): String = this::class.simpleName ?: super.toString() - } - - abstract class Model : Data() - - abstract class Intent : Data() - - abstract class Controller(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 deleted file mode 100644 index 4325df27..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/CloseChannelsConfiguration.kt +++ /dev/null @@ -1,42 +0,0 @@ -package fr.acinq.phoenix.controllers.config - -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.Satoshi -import fr.acinq.lightning.blockchain.fee.FeeratePerKw -import fr.acinq.phoenix.controllers.MVI - -object CloseChannelsConfiguration { - - sealed class Model : MVI.Model() { - - object Loading : Model() - data class Ready( - val channels: List, - 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 deleted file mode 100644 index edc22189..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/CloseChannelsConfigurationController.kt +++ /dev/null @@ -1,172 +0,0 @@ -package fr.acinq.phoenix.controllers.config - -import fr.acinq.bitcoin.ByteVector -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.Chain -import fr.acinq.lightning.blockchain.fee.FeeratePerByte -import fr.acinq.lightning.channel.ChannelCommand -import fr.acinq.lightning.channel.states.Aborted -import fr.acinq.lightning.channel.states.ChannelState -import fr.acinq.lightning.channel.states.Closed -import fr.acinq.lightning.channel.states.Closing -import fr.acinq.lightning.channel.states.Normal -import fr.acinq.lightning.channel.states.Offline -import fr.acinq.lightning.channel.states.Syncing -import fr.acinq.lightning.io.WrappedChannelCommand -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.lightning.logging.info -import fr.acinq.phoenix.PhoenixBusiness -import fr.acinq.phoenix.controllers.AppController -import fr.acinq.phoenix.controllers.config.CloseChannelsConfiguration.Model.ChannelInfoStatus -import fr.acinq.phoenix.managers.AppConfigurationManager -import fr.acinq.phoenix.managers.PeerManager -import fr.acinq.phoenix.managers.phoenixFinalWallet -import fr.acinq.phoenix.utils.Parser -import fr.acinq.phoenix.utils.extensions.localBalance -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.launch - -class AppCloseChannelsConfigurationController( - loggerFactory: LoggerFactory, - private val peerManager: PeerManager, - private val appConfigurationManager: AppConfigurationManager, - private val chain: Chain, - private val isForceClose: Boolean -) : AppController( - 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 deleted file mode 100644 index 244424d5..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/Configuration.kt +++ /dev/null @@ -1,13 +0,0 @@ -package fr.acinq.phoenix.controllers.config - -import fr.acinq.phoenix.controllers.MVI - -object Configuration { - - sealed class Model : MVI.Model() { - object SimpleMode : Model() - object FullMode : Model() - } - - sealed class Intent : MVI.Intent() -} 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 deleted file mode 100644 index a8981606..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/ConfigurationController.kt +++ /dev/null @@ -1,34 +0,0 @@ -package fr.acinq.phoenix.controllers.config - -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.phoenix.PhoenixBusiness -import fr.acinq.phoenix.controllers.AppController -import fr.acinq.phoenix.managers.WalletManager -import kotlinx.coroutines.launch - - -class AppConfigurationController( - loggerFactory: LoggerFactory, - private val walletManager: WalletManager -) : AppController( - 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 deleted file mode 100644 index eabb3e6c..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/ElectrumConfiguration.kt +++ /dev/null @@ -1,26 +0,0 @@ -package fr.acinq.phoenix.controllers.config - -import fr.acinq.lightning.utils.Connection -import fr.acinq.lightning.utils.ServerAddress -import fr.acinq.phoenix.controllers.MVI -import fr.acinq.phoenix.data.ElectrumConfig - -object ElectrumConfiguration { - - data class Model( - val configuration: ElectrumConfig? = null, - val currentServer: ServerAddress? = null, - val connection: Connection = Connection.CLOSED(reason = null), - val feeRate: Long = 0, - val blockHeight: Int = 0, - val tipTimestamp: Long = 0, - val walletIsInitialized: Boolean = false, - val error: Error? = null - ) : MVI.Model() { - fun isCustom() = configuration != null && configuration is ElectrumConfig.Custom - } - - sealed class Intent : MVI.Intent() { - data class UpdateElectrumServer(val config: ElectrumConfig.Custom?) : Intent() - } -} 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 deleted file mode 100644 index 92553cb3..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/config/ElectrumConfigurationController.kt +++ /dev/null @@ -1,60 +0,0 @@ -package fr.acinq.phoenix.controllers.config - -import fr.acinq.lightning.blockchain.electrum.ElectrumClient -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.phoenix.PhoenixBusiness -import fr.acinq.phoenix.controllers.AppController -import fr.acinq.phoenix.managers.AppConfigurationManager -import fr.acinq.phoenix.managers.AppConnectionsDaemon -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch - - -class AppElectrumConfigurationController( - loggerFactory: LoggerFactory, - private val configurationManager: AppConfigurationManager, - private val electrumClient: ElectrumClient, - private val appConnectionsDaemon: AppConnectionsDaemon? -) : AppController( - 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 deleted file mode 100644 index 090009ba..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/InitController.kt +++ /dev/null @@ -1,34 +0,0 @@ -package fr.acinq.phoenix.controllers.init - -import fr.acinq.bitcoin.MnemonicCode -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.phoenix.PhoenixBusiness -import fr.acinq.phoenix.controllers.AppController -import kotlinx.coroutines.launch - - -class AppInitController( - loggerFactory: LoggerFactory -) : AppController( - 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 deleted file mode 100644 index 01df392b..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/Initialization.kt +++ /dev/null @@ -1,29 +0,0 @@ -package fr.acinq.phoenix.controllers.init - -import fr.acinq.phoenix.controllers.MVI -import fr.acinq.phoenix.utils.MnemonicLanguage - - -object Initialization { - - sealed class Model : MVI.Model() { - object Ready : Model() - data class GeneratedWallet( - val mnemonics: List, - 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 deleted file mode 100644 index 7e5fb805..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/RestoreWallet.kt +++ /dev/null @@ -1,78 +0,0 @@ -package fr.acinq.phoenix.controllers.init - -import fr.acinq.phoenix.controllers.MVI -import fr.acinq.phoenix.utils.MnemonicLanguage - -object RestoreWallet { - - sealed class Model : MVI.Model() { - object Ready : Model() - - data class FilteredWordlist( - val uuid: String, - val predicate: String, - val words: List - ) : 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 - return seed.contentEquals(other.seed) - } - - override fun hashCode(): Int { - return seed.contentHashCode() - } - - override fun toString() = "ValidMnemonics" - } - } - - sealed class Intent : MVI.Intent() { - data class FilterWordList( - val predicate: String, - val language: MnemonicLanguage, - val uuid: String = "" // See note below - ) : Intent() { - // We are using StateFlow to handle model changes. - // The problem is that StateFlow is conflated, - // so it will silently drop notifications if the model doesn't change. - // As per issue #109, we encountered problems with this. - // For example, if the user pastes in a seed such as "hammer hammer ...", - // then what happens is: - // - // - UI calls intent with FilterWordList(hammer) // 1st word - // - model is updated to FilteredWordlist([hammer]) - // - UI is notified - // - UI calls intent with FilterWordList(hammer) // 2nd word - // - model is updated to FilteredWordlist([hammer]) - // - UI is NOT updated, because the model didn't change ! - // - // So the uuid is a workaround, to force a model change everytime. - // - // Another possible solution is to switch from MutableStateFlow to MutableShareFlow. - // However, doing so would affect AppController.kt, which effects every MVI. - // So we're reserving that as a potential future change. - - override fun toString() = ".".repeat(predicate.length) - } - data class Validate( - val mnemonics: List, - 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 deleted file mode 100644 index ab6c645a..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/init/RestoreWalletController.kt +++ /dev/null @@ -1,64 +0,0 @@ -package fr.acinq.phoenix.controllers.init - -import fr.acinq.bitcoin.MnemonicCode -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.phoenix.PhoenixBusiness -import fr.acinq.phoenix.controllers.AppController -import kotlinx.coroutines.launch - - -class AppRestoreWalletController( - loggerFactory: LoggerFactory -) : AppController( - 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 deleted file mode 100644 index c52bdc74..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/Content.kt +++ /dev/null @@ -1,15 +0,0 @@ -package fr.acinq.phoenix.controllers.main - -import fr.acinq.phoenix.controllers.MVI - -object Content { - - sealed class Model : MVI.Model() { - object Waiting : Model() - object IsInitialized : Model() - object NeedInitialization : Model() - } - - sealed class Intent : MVI.Intent() - -} 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 deleted file mode 100644 index f9b3187a..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/ContentController.kt +++ /dev/null @@ -1,38 +0,0 @@ -package fr.acinq.phoenix.controllers.main - -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.phoenix.PhoenixBusiness -import fr.acinq.phoenix.controllers.AppController -import fr.acinq.phoenix.managers.WalletManager -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch - - -class AppContentController( - loggerFactory: LoggerFactory, - private val walletManager: WalletManager -) : AppController( - 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 deleted file mode 100644 index ef511422..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/Home.kt +++ /dev/null @@ -1,17 +0,0 @@ -package fr.acinq.phoenix.controllers.main - -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.phoenix.controllers.MVI - -object Home { - - data class Model( - val balance: MilliSatoshi?, - ) : MVI.Model() - - val emptyModel = Model( - balance = null, - ) - - sealed class Intent : MVI.Intent() -} 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 deleted file mode 100644 index 73677077..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/main/HomeController.kt +++ /dev/null @@ -1,31 +0,0 @@ -package fr.acinq.phoenix.controllers.main - -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.phoenix.PhoenixBusiness -import fr.acinq.phoenix.controllers.AppController -import fr.acinq.phoenix.managers.BalanceManager -import kotlinx.coroutines.launch - - -class AppHomeController( - loggerFactory: LoggerFactory, - private val balanceManager: BalanceManager -) : AppController( - 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 deleted file mode 100644 index c4923aab..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/payments/Receive.kt +++ /dev/null @@ -1,22 +0,0 @@ -package fr.acinq.phoenix.controllers.payments - -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.phoenix.controllers.MVI - -object Receive { - - sealed class Model : MVI.Model() { - object Awaiting : Model() - object Generating: Model() - data class Generated(val request: String, val paymentHash: String, val amount: MilliSatoshi?, val desc: String?): Model() - } - - sealed class Intent : MVI.Intent() { - data class Ask( - val amount: MilliSatoshi?, - val desc: String?, - val expirySeconds: Long = 3600 * 24 * 7 // 7 days - ) : Intent() - } - -} 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 deleted file mode 100644 index 22fe1552..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/controllers/payments/ReceiveController.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2022 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.controllers.payments - -import fr.acinq.bitcoin.utils.Either -import fr.acinq.lightning.Lightning.randomBytes32 -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.phoenix.PhoenixBusiness -import fr.acinq.phoenix.controllers.AppController -import fr.acinq.phoenix.managers.PeerManager -import kotlinx.coroutines.launch -import kotlin.time.Duration.Companion.seconds - - -class AppReceiveController( - loggerFactory: LoggerFactory, - private val peerManager: PeerManager, -) : AppController( - 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 deleted file mode 100644 index 3072d570..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/csv/CsvWriter.kt +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.csv - -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.phoenix.data.ExchangeRate -import kotlin.math.absoluteValue - -open class CsvWriter { - private val sb: StringBuilder = StringBuilder() - - fun addRow(vararg fields: String) { - val cleanFields = fields.map { processField(it) } - sb.append(cleanFields.joinToString(separator = ",", postfix = "\n")) - } - - fun addRow(fields: List) { - 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 deleted file mode 100644 index c9d12c2f..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/csv/WalletPaymentCsvWriter.kt +++ /dev/null @@ -1,288 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.csv - -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.Satoshi -import fr.acinq.bitcoin.TxId -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.db.AutomaticLiquidityPurchasePayment -import fr.acinq.lightning.db.Bolt11IncomingPayment -import fr.acinq.lightning.db.ChannelCloseOutgoingPayment -import fr.acinq.lightning.db.LegacyPayToOpenIncomingPayment -import fr.acinq.lightning.db.LegacySwapInIncomingPayment -import fr.acinq.lightning.db.LightningIncomingPayment -import fr.acinq.lightning.db.LightningOutgoingPayment -import fr.acinq.lightning.db.ManualLiquidityPurchasePayment -import fr.acinq.lightning.db.OnChainIncomingPayment -import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment -import fr.acinq.lightning.db.SpliceOutgoingPayment -import fr.acinq.lightning.db.WalletPayment -import fr.acinq.lightning.utils.UUID -import fr.acinq.lightning.utils.msat -import fr.acinq.lightning.utils.sat -import fr.acinq.lightning.utils.sum -import fr.acinq.lightning.utils.toMilliSatoshi -import fr.acinq.phoenix.data.WalletPaymentMetadata -import kotlin.time.ExperimentalTime -import kotlin.time.Instant - -class WalletPaymentCsvWriter(val configuration: Configuration) : CsvWriter() { - - data class Configuration( - val includesFiat: Boolean, - val includesDescription: Boolean, - val includesNotes: Boolean, - val includesOriginDestination: Boolean, - ) - - private val FIELD_DATE = "date" - private val FIELD_ID = "id" - private val FIELD_TYPE = "type" - private val FIELD_AMOUNT_MSAT = "amount_msat" - private val FIELD_AMOUNT_FIAT = "amount_fiat" - private val FIELD_FEE_CREDIT_MSAT = "fee_credit_msat" - private val FIELD_MINING_FEE_SAT = "mining_fee_sat" - private val FIELD_MINING_FEE_FIAT = "mining_fee_fiat" - private val FIELD_SERVICE_FEE_MSAT = "service_fee_msat" - private val FIELD_SERVICE_FEE_FIAT = "service_fee_fiat" - private val FIELD_PAYMENT_HASH = "payment_hash" - private val FIELD_TX_ID = "tx_id" - private val FIELD_DESTINATION = "destination" - private val FIELD_DESCRIPTION = "description" - - init { - addRow( - FIELD_DATE, - FIELD_ID, - FIELD_TYPE, - FIELD_AMOUNT_MSAT, - FIELD_AMOUNT_FIAT, - FIELD_FEE_CREDIT_MSAT, - FIELD_MINING_FEE_SAT, - FIELD_MINING_FEE_FIAT, - FIELD_SERVICE_FEE_MSAT, - FIELD_SERVICE_FEE_FIAT, - FIELD_PAYMENT_HASH, - FIELD_TX_ID, - FIELD_DESTINATION, - FIELD_DESCRIPTION - ) - } - - @Suppress("EnumEntryName") - enum class Type { - legacy_swap_in, - legacy_swap_out, - legacy_pay_to_open, - swap_in, - swap_out, - fee_bumping, - lightning_received, - lightning_sent, - liquidity_purchase, - channel_close, - } - - data class Details( - val type: Type, - val amount: MilliSatoshi, - val feeCredit: MilliSatoshi, - val miningFee: Satoshi, - val serviceFee: MilliSatoshi, - val paymentHash: ByteVector32?, - val txId: TxId?, - val destination: String? = null, - val description: String? = null, - ) - - @OptIn(ExperimentalTime::class) - private fun addRow( - timestamp: Long, - id: UUID, - details: Details, - metadata: WalletPaymentMetadata?, - ) { - val dateStr = Instant.fromEpochMilliseconds(timestamp).toString() // ISO-8601 format - val originalFiat = metadata?.originalFiat - addRow( - dateStr, - id.toString(), - details.type.toString(), - details.amount.msat.toString(), - if (configuration.includesFiat) convertToFiat(details.amount, originalFiat) else "", - details.feeCredit.msat.toString(), - details.miningFee.sat.toString(), - if (configuration.includesFiat) convertToFiat(details.miningFee.toMilliSatoshi(), originalFiat) else "", - details.serviceFee.msat.toString(), - if (configuration.includesFiat) convertToFiat(details.serviceFee, originalFiat) else "", - details.paymentHash?.toHex() ?: "", - if (configuration.includesOriginDestination) details.txId?.toString() ?: "" else "", - if (configuration.includesOriginDestination) details.destination ?: "" else "", - if (configuration.includesDescription) listOf( - details.description, metadata?.userDescription, metadata?.userNotes, metadata?.lnurl?.pay?.metadata?.longDesc - ).mapNotNull { it.takeIf { !it.isNullOrBlank() } }.joinToString("\n---\n") else "", - ) - } - - @Suppress("DEPRECATION") - fun add(payment: WalletPayment, metadata: WalletPaymentMetadata?) { - val timestamp = payment.completedAt ?: payment.createdAt - val id = payment.id - - val details: Details? = when (payment) { - is LightningIncomingPayment -> Details( - type = Type.lightning_received, - amount = payment.amountReceived, - feeCredit = payment.parts.filterIsInstance().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 deleted file mode 100644 index 1ecd2880..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/AppConfiguration.kt +++ /dev/null @@ -1,296 +0,0 @@ -package fr.acinq.phoenix.data - -import fr.acinq.lightning.io.TcpSocket -import fr.acinq.lightning.payment.LiquidityPolicy -import fr.acinq.lightning.utils.ServerAddress -import fr.acinq.phoenix.utils.extensions.isOnion -import kotlinx.serialization.Serializable -import org.jetbrains.compose.resources.getString -import mantra.composeapp.generated.resources.Res -import mantra.composeapp.generated.resources.currency_ars_bm -import mantra.composeapp.generated.resources.currency_ars_official -import mantra.composeapp.generated.resources.currency_cup_fm -import mantra.composeapp.generated.resources.currency_cup_official -import mantra.composeapp.generated.resources.currency_lbp_bm -import mantra.composeapp.generated.resources.currency_lbp_official - - -sealed interface CurrencyUnit { - /** Code that should be displayed in the UI. */ - val displayCode: String -} - -@Serializable -enum class BitcoinUnit(override val displayCode: String) : CurrencyUnit { - Sat("sat"), Bit("bit"), MBtc("mbtc"), Btc("btc"); - - override fun toString(): String { - return super.toString().lowercase() - } - - companion object { - val values = entries - - fun valueOfOrNull(code: String): BitcoinUnit? = try { - valueOf(code) - } catch (e: Exception) { - null - } - } -} - -/** - * @param flag when multiple countries use that currency, use the flag of the country with highest GDP - */ -@Serializable -enum class FiatCurrency(override val displayCode: String, val flag: String = "🏳️") : CurrencyUnit { - AED(displayCode = "AED", flag = "🇦🇪"), // United Arab Emirates Dirham - AFN(displayCode = "AFN", flag = "🇦🇫"), // Afghan Afghani - ALL(displayCode = "ALL", flag = "🇦🇱"), // Albanian Lek - AMD(displayCode = "AMD", flag = "🇦🇲"), // Armenian Dram - ANG(displayCode = "ANG", flag = "🇳🇱"), // Netherlands Antillean Guilder - AOA(displayCode = "AOA", flag = "🇦🇴"), // Angolan Kwanza - ARS_BM(displayCode = "ARS", flag = "🇦🇷"), // Argentine Peso (blue market) - ARS(displayCode = "ARS_OFF", flag = "🇦🇷"), // Argentine Peso (official rate) - AUD(displayCode = "AUD", flag = "🇦🇺"), // Australian Dollar - AWG(displayCode = "AWG", flag = "🇦🇼"), // Aruban Florin - AZN(displayCode = "AZN", flag = "🇦🇿"), // Azerbaijani Manat - BAM(displayCode = "BAM", flag = "🇧🇦"), // Bosnia-Herzegovina Convertible Mark - BBD(displayCode = "BBD", flag = "🇧🇧"), // Barbadian Dollar - BDT(displayCode = "BDT", flag = "🇧🇩"), // Bangladeshi Taka - BGN(displayCode = "BGN", flag = "🇧🇬"), // Bulgarian Lev - BHD(displayCode = "BHD", flag = "🇧🇭"), // Bahraini Dinar - BIF(displayCode = "BIF", flag = "🇧🇮"), // Burundian Franc - BMD(displayCode = "BMD", flag = "🇧🇲"), // Bermudan Dollar - BND(displayCode = "BND", flag = "🇧🇳"), // Brunei Dollar - BOB(displayCode = "BOB", flag = "🇧🇴"), // Bolivian Boliviano - BRL(displayCode = "BRL", flag = "🇧🇷"), // Brazilian Real - BSD(displayCode = "BSD", flag = "🇧🇸"), // Bahamian Dollar - BTN(displayCode = "BTN", flag = "🇧🇹"), // Bhutanese Ngultrum - BWP(displayCode = "BWP", flag = "🇧🇼"), // Botswanan Pula - BZD(displayCode = "BZD", flag = "🇧🇿"), // Belize Dollar - CAD(displayCode = "CAD", flag = "🇨🇦"), // Canadian Dollar - CDF(displayCode = "CDF", flag = "🇨🇩"), // Congolese Franc - CHF(displayCode = "CHF", flag = "🇨🇭"), // Swiss Franc - CLP(displayCode = "CLP", flag = "🇨🇱"), // Chilean Peso - CNH(displayCode = "CNH", flag = "🇨🇳"), // Chinese Yuan (offshore) - CNY(displayCode = "CNY", flag = "🇨🇳"), // Chinese Yuan (onshore) - COP(displayCode = "COP", flag = "🇨🇴"), // Colombian Peso - CRC(displayCode = "CRC", flag = "🇨🇷"), // Costa Rican Colón - CUP_FM(displayCode = "CUP", flag = "🇨🇺"), // Cuban Peso (free market) - CUP(displayCode = "CUP_OFF", flag = "🇨🇺"), // Cuban Peso (official rate) - CVE(displayCode = "CVE", flag = "🇨🇻"), // Cape Verdean Escudo - CZK(displayCode = "CZK", flag = "🇨🇿"), // Czech Republic Koruna - DJF(displayCode = "DJF", flag = "🇩🇯"), // Djiboutian Franc - DKK(displayCode = "DKK", flag = "🇩🇰"), // Danish Krone - DOP(displayCode = "DOP", flag = "🇩🇴"), // Dominican Peso - DZD(displayCode = "DZD", flag = "🇩🇿"), // Algerian Dinar - EGP(displayCode = "EGP", flag = "🇪🇬"), // Egyptian Pound - ERN(displayCode = "ERN", flag = "🇪🇷"), // Eritrean Nakfa - ETB(displayCode = "ETB", flag = "🇪🇹"), // Ethiopian Birr - EUR(displayCode = "EUR", flag = "🇪🇺"), // Euro - FJD(displayCode = "FJD", flag = "🇫🇯"), // Fijian Dollar - FKP(displayCode = "FKP", flag = "🇫🇰"), // Falkland Islands Pound - GBP(displayCode = "GBP", flag = "🇬🇧"), // British Pound Sterling - GEL(displayCode = "GEL", flag = "🇬🇪"), // Georgian Lari - GHS(displayCode = "GHS", flag = "🇬🇭"), // Ghanaian Cedi - GIP(displayCode = "GIP", flag = "🇬🇮"), // Gibraltar Pound - GMD(displayCode = "GMD", flag = "🇬🇲"), // Gambian Dalasi - GNF(displayCode = "GNF", flag = "🇬🇳"), // Guinean Franc - GTQ(displayCode = "GTQ", flag = "🇬🇹"), // Guatemalan Quetzal - GYD(displayCode = "GYD", flag = "🇬🇾"), // Guyanaese Dollar - HKD(displayCode = "HKD", flag = "🇭🇰"), // Hong Kong Dollar - HNL(displayCode = "HNL", flag = "🇭🇳"), // Honduran Lempira - HRK(displayCode = "HRK", flag = "🇭🇷"), // Croatian Kuna - HTG(displayCode = "HTG", flag = "🇭🇹"), // Haitian Gourde - HUF(displayCode = "HUF", flag = "🇭🇺"), // Hungarian Forint - IDR(displayCode = "IDR", flag = "🇮🇩"), // Indonesian Rupiah - ILS(displayCode = "ILS", flag = "🇮🇱"), // Israeli New Sheqel - INR(displayCode = "INR", flag = "🇮🇳"), // Indian Rupee - IQD(displayCode = "IQD", flag = "🇮🇶"), // Iraqi Dinar - IRR(displayCode = "IRR", flag = "🇮🇷"), // Iranian Rial - ISK(displayCode = "ISK", flag = "🇮🇸"), // Icelandic Króna - JEP(displayCode = "JEP", flag = "🇯🇪"), // Jersey Pound - JMD(displayCode = "JMD", flag = "🇯🇲"), // Jamaican Dollar - JOD(displayCode = "JOD", flag = "🇯🇴"), // Jordanian Dinar - JPY(displayCode = "JPY", flag = "🇯🇵"), // Japanese Yen - KES(displayCode = "KES", flag = "🇰🇪"), // Kenyan Shilling - KGS(displayCode = "KGS", flag = "🇰🇬"), // Kyrgystani Som - KHR(displayCode = "KHR", flag = "🇰🇭"), // Cambodian Riel - KMF(displayCode = "KMF", flag = "🇰🇲"), // Comorian Franc - KPW(displayCode = "KPW", flag = "🇰🇵"), // North Korean Won - KRW(displayCode = "KRW", flag = "🇰🇷"), // South Korean Won - KWD(displayCode = "KWD", flag = "🇰🇼"), // Kuwaiti Dinar - KYD(displayCode = "KYD", flag = "🇰🇾"), // Cayman Islands Dollar - KZT(displayCode = "KZT", flag = "🇰🇿"), // Kazakhstani Tenge - LAK(displayCode = "LAK", flag = "🇱🇦"), // Laotian Kip - LBP_BM(displayCode = "LBP", flag = "🇱🇧"), // Lebanese Pound (black market) - LBP(displayCode = "LBP_OFF", flag = "🇱🇧"), // Lebanese Pound (official rate) - LKR(displayCode = "LKR", flag = "🇱🇰"), // Sri Lankan Rupee - LRD(displayCode = "LRD", flag = "🇱🇷"), // Liberian Dollar - LSL(displayCode = "LSL", flag = "🇱🇸"), // Lesotho Loti - LYD(displayCode = "LYD", flag = "🇱🇾"), // Libyan Dinar - MAD(displayCode = "MAD", flag = "🇲🇦"), // Moroccan Dirham - MDL(displayCode = "MDL", flag = "🇲🇩"), // Moldovan Leu - MGA(displayCode = "MGA", flag = "🇲🇬"), // Malagasy Ariary - MKD(displayCode = "MKD", flag = "🇲🇰"), // Macedonian Denar - MMK(displayCode = "MMK", flag = "🇲🇲"), // Myanma Kyat - MNT(displayCode = "MNT", flag = "🇲🇳"), // Mongolian Tugrik - MOP(displayCode = "MOP", flag = "🇲🇴"), // Macanese Pataca - MUR(displayCode = "MUR", flag = "🇲🇺"), // Mauritian Rupee - MVR(displayCode = "MVR", flag = "🇲🇻"), // Maldivian Rufiyaa - MWK(displayCode = "MWK", flag = "🇲🇼"), // Malawian Kwacha - MXN(displayCode = "MXN", flag = "🇲🇽"), // Mexican Peso - MYR(displayCode = "MYR", flag = "🇲🇾"), // Malaysian Ringgit - MZN(displayCode = "MZN", flag = "🇲🇿"), // Mozambican Metical - NAD(displayCode = "NAD", flag = "🇳🇦"), // Namibian Dollar - NGN(displayCode = "NGN", flag = "🇳🇬"), // Nigerian Naira - NIO(displayCode = "NIO", flag = "🇳🇮"), // Nicaraguan Córdoba - NOK(displayCode = "NOK", flag = "🇳🇴"), // Norwegian Krone - NPR(displayCode = "NPR", flag = "🇳🇵"), // Nepalese Rupee - NZD(displayCode = "NZD", flag = "🇳🇿"), // New Zealand Dollar - OMR(displayCode = "OMR", flag = "🇴🇲"), // Omani Rial - PAB(displayCode = "PAB", flag = "🇵🇦"), // Panamanian Balboa - PEN(displayCode = "PEN", flag = "🇵🇪"), // Peruvian Sol - PGK(displayCode = "PGK", flag = "🇵🇬"), // Papua New Guinean Kina - PHP(displayCode = "PHP", flag = "🇵🇭"), // Philippine Peso - PKR(displayCode = "PKR", flag = "🇵🇰"), // Pakistani Rupee - PLN(displayCode = "PLN", flag = "🇵🇱"), // Polish Zloty - PYG(displayCode = "PYG", flag = "🇵🇾"), // Paraguayan Guarani - QAR(displayCode = "QAR", flag = "🇶🇦"), // Qatari Rial - RON(displayCode = "RON", flag = "🇷🇴"), // Romanian Leu - RSD(displayCode = "RSD", flag = "🇷🇸"), // Serbian Dinar - RUB(displayCode = "RUB", flag = "🇷🇺"), // Russian Ruble - RWF(displayCode = "RWF", flag = "🇷🇼"), // Rwandan Franc - SAR(displayCode = "SAR", flag = "🇸🇦"), // Saudi Riyal - SBD(displayCode = "SBD", flag = "🇸🇧"), // Solomon Islands Dollar - SCR(displayCode = "SCR", flag = "🇸🇨"), // Seychellois Rupee - SDG(displayCode = "SDG", flag = "🇸🇩"), // Sudanese Pound - SEK(displayCode = "SEK", flag = "🇸🇪"), // Swedish Krona - SGD(displayCode = "SGD", flag = "🇸🇬"), // Singapore Dollar - SHP(displayCode = "SHP", flag = "🇸🇭"), // Saint Helena Pound - SLL(displayCode = "SLL", flag = "🇸🇱"), // Sierra Leonean Leone - SOS(displayCode = "SOS", flag = "🇸🇴"), // Somali Shilling - SRD(displayCode = "SRD", flag = "🇸🇷"), // Surinamese Dollar - SYP(displayCode = "SYP", flag = "🇸🇾"), // Syrian Pound - SZL(displayCode = "SZL", flag = "🇸🇿"), // Swazi Lilangeni - THB(displayCode = "THB", flag = "🇹🇭"), // Thai Baht - TJS(displayCode = "TJS", flag = "🇹🇯"), // Tajikistani Somoni - TMT(displayCode = "TMT", flag = "🇹🇲"), // Turkmenistani Manat - TND(displayCode = "TND", flag = "🇹🇳"), // Tunisian Dinar - TOP(displayCode = "TOP", flag = "🇹🇴"), // Tongan Paʻanga - TRY(displayCode = "TRY", flag = "🇹🇷"), // Turkish Lira - TTD(displayCode = "TTD", flag = "🇹🇹"), // Trinidad and Tobago Dollar - TWD(displayCode = "TWD", flag = "🇹🇼"), // Taiwan Dollar - TZS(displayCode = "TZS", flag = "🇹🇿"), // Tanzanian Shilling - UAH(displayCode = "UAH", flag = "🇺🇦"), // Ukrainian Hryvnia - UGX(displayCode = "UGX", flag = "🇺🇬"), // Ugandan Shilling - USD(displayCode = "USD", flag = "🇺🇸"), // United States Dollar - UYU(displayCode = "UYU", flag = "🇺🇾"), // Uruguayan Peso - UZS(displayCode = "UZS", flag = "🇺🇿"), // Uzbekistan Som - VND(displayCode = "VND", flag = "🇻🇳"), // Vietnamese Dong - VUV(displayCode = "VUV", flag = "🇻🇺"), // Vanuatu Vatu - WST(displayCode = "WST", flag = "🇼🇸"), // Samoan Tala - XAF(displayCode = "XAF", flag = "🇨🇲"), // CFA Franc BEAC - XCD(displayCode = "XCD", flag = "🇱🇨"), // East Caribbean Dollar - XOF(displayCode = "XOF", flag = "🇨🇮"), // CFA Franc BCEAO - XPF(displayCode = "XPF", flag = "🇳🇨"), // CFP Franc - YER(displayCode = "YER", flag = "🇾🇪"), // Yemeni Rial - ZAR(displayCode = "ZAR", flag = "🇿🇦"), // South African Rand - ZMW(displayCode = "ZMW", flag = "🇿🇲"); // Zambian Kwacha - - /** Returns a pair of string (short name, full name) for this [fiatCurrency]. */ - suspend fun getLabel(): Pair { - val fullName = when { - // use the free market rates as default. Name for official rates gets a special tag, as those rates are usually inaccurate. - this == ARS -> getString(Res.string.currency_ars_official) - this == ARS_BM -> getString(Res.string.currency_ars_bm) - this == CUP -> getString(Res.string.currency_cup_official) - this == CUP_FM -> getString(Res.string.currency_cup_fm) - this == LBP -> getString(Res.string.currency_lbp_official) - this == LBP_BM -> getString(Res.string.currency_lbp_bm) - // use the JVM API otherwise to get the name - this.displayCode.length == 3 -> try { - this.name - } catch (e: Exception) { - "N/A" - } - else -> "N/A" - } - return "${this.flag} ${this.displayCode}" to fullName - } - - companion object { - val values = entries - fun valueOfOrNull(code: String): FiatCurrency? = try { - valueOf(code) - } catch (e: Exception) { - null - } - } -} - -sealed class ElectrumConfig { - /** - * Note : constructor is private because we want to enforce a disabled tls policy on onion hosts. - * - * @param requireOnionIfTorEnabled if this option is true, Phoenix will require this custom server to use an onion address when Tor is enabled, otherwise - * it will not connect to it. This parameter should be true in the normal case. However, the user may want to override this requirement, for - * example, if he's connecting to his own server and does not care about leaking his IP if the Tor proxy fails. - */ - class Custom private constructor(val server: ServerAddress, val requireOnionIfTorEnabled: Boolean) : ElectrumConfig() { - companion object { - fun create(server: ServerAddress, requireOnionIfTorEnabled: Boolean) = Custom( - server = if (server.isOnion) server.copy(tls = TcpSocket.TLS.DISABLED) else server, - requireOnionIfTorEnabled = requireOnionIfTorEnabled - ) - } - } - object Random : ElectrumConfig() - - override operator fun equals(other: Any?): Boolean { - if (other !is ElectrumConfig) { - return false - } - return when (this) { - is Custom -> { - when (other) { - is Custom -> this === other // custom =?= custom - is Random -> false // custom != random - } - } - is Random -> { - when (other) { - is Custom -> false // random != custom - is Random -> true // random == random - } - } - } - } -} - -data class StartupParams( - /** If true, we'll use onion addresses when connecting to the peer and to Electrum servers. */ - val isTorEnabled: Boolean, - /** The liquidity policy must be injected into the node params manager. */ - val liquidityPolicy: LiquidityPolicy, - // TODO: add custom electrum address, fiat currencies, ... -) - -@Serializable -data class PreferredFiatCurrencies( - val primary: FiatCurrency, - val others: Set -) { - 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 deleted file mode 100644 index d3643839..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/BitcoinAddress.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2022 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.data - -import fr.acinq.bitcoin.BitcoinError -import fr.acinq.bitcoin.ByteVector -import fr.acinq.bitcoin.Chain -import fr.acinq.bitcoin.Satoshi -import fr.acinq.lightning.payment.Bolt11Invoice -import fr.acinq.lightning.wire.OfferTypes -import io.ktor.http.Parameters -import io.ktor.http.formUrlEncode -import io.ktor.http.plus - -data class BitcoinUri( - val chain: Chain, - /** Actual Bitcoin address; may be different than the source, e.g. if the source is an URI like "bitcoin:xyz?param=123". */ - val address: String, - val script: ByteVector?, - // Bip-21 parameters - val label: String? = null, - val message: String? = null, - /** Amount requested in the URI. */ - val amount: Satoshi? = null, - /** A Bitcoin URI may contain a Bolt11 invoice or an offer as an alternative way to make the payment. */ - val paymentRequest: Bolt11Invoice? = null, - val offer: OfferTypes.Offer? = null, - /** Other bip-21 parameters in the URI that we do not handle. */ - val ignoredParams: Parameters = Parameters.Empty, -) { - fun write(): String { - val params = Parameters.build { - label?.let { append("label", it) } - message?.let { append("message", it) } - // amount field is converted to BTC - amount?.sat?.toString()?.padStart(9, '0')?.let { - val satPart = it.takeLast(8) - val btcPart = it.substring(0, it.length - 8) - append("amount", "${btcPart}.${satPart}") - } - paymentRequest?.let { append("lightning", it.write()) } - offer?.let { append("lno", it.encode()) } - } - return "bitcoin:$address?${(params + ignoredParams).formUrlEncode()}" - } -} - -sealed class BitcoinUriError { - data class InvalidScript(val error: BitcoinError): BitcoinUriError() - data class UnhandledRequiredParams(val parameters: List>): 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 deleted file mode 100644 index 3572b607..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ChannelsWatcherOutcome.kt +++ /dev/null @@ -1,17 +0,0 @@ -package fr.acinq.phoenix.data - -import kotlinx.serialization.Serializable - -@Serializable -sealed class ChannelsWatcherOutcome { - abstract val timestamp: Long - - @Serializable - data class Unknown(override val timestamp: Long) : ChannelsWatcherOutcome() - - @Serializable - data class Nominal(override val timestamp: Long) : ChannelsWatcherOutcome() - - @Serializable - data class RevokedFound(override val timestamp: Long) : ChannelsWatcherOutcome() -} \ 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 deleted file mode 100644 index e8d989cb..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ContactInfo.kt +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.data - -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.Crypto -import fr.acinq.bitcoin.PublicKey -import fr.acinq.bitcoin.byteVector32 -import fr.acinq.lightning.utils.UUID -import fr.acinq.lightning.utils.currentTimestampMillis -import fr.acinq.lightning.wire.OfferTypes -import io.ktor.utils.io.charsets.Charsets -import io.ktor.utils.io.core.toByteArray - -sealed class ContactPaymentCode { - abstract val id: ByteVector32 - abstract val label: String? - abstract val createdAt: Long - abstract val paymentCode: String -} - -data class ContactOffer( - override val id: ByteVector32, - val offer: OfferTypes.Offer, - override val label: String?, - override val createdAt: Long -) : ContactPaymentCode() { - - override val paymentCode: String by lazy { offer.encode() } - - constructor(offer: OfferTypes.Offer, label: String?, createdAt: Long? = null) : this( - id = offer.offerId, // see note below - offer = offer, - label = label, - createdAt = createdAt ?: currentTimestampMillis() - ) - - // We purposefully store the calculated `offer.offerId` as a property, - // because the `offerId` property itself is actually a result of hashing and - // other calculations. In other words it's not cheap to compute. - // And it's a value we reference regularly within the UI. -} - -data class ContactAddress( - override val id: ByteVector32, - val address: String, - override val label: String?, - override val createdAt: Long -) : ContactPaymentCode() { - - override val paymentCode: String = address - - constructor(address: String, label: String?, createdAt: Long? = null) : this( - id = hash(address), // see note below - address = address, - label = label, - createdAt = createdAt ?: currentTimestampMillis() - ) - - // We purposefully store the calculated `hash(address)` as a property, - // because the value is a result of hashing. So it's not cheap to compute. - // And it's a value we reference regularly within the UI. - - companion object { - fun hash(address: String): ByteVector32 { - val input = address.lowercase().toByteArray(charset = Charsets.UTF_8) - return Crypto.sha256(input).byteVector32() - } - } -} - -data class ContactInfo( - val id: UUID, - val name: String, - val photoUri: String?, - val useOfferKey: Boolean, - val offers: List, - 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 deleted file mode 100644 index 44f70c85..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/DecryptSeedResult.kt +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 7b09af6e..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/DefaultOffer.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.data - -import fr.acinq.bitcoin.PrivateKey -import fr.acinq.lightning.wire.OfferTypes - -/** - * @param defaultOffer The default offer for a node. - * @param payerKey A private key attached to a node. It can be used to sign payments to offers of - * third parties and prove the origin of that payment. The recipient of that payment can then - * decide that this origin is trusted, and show/hide the `payerNote` attached to that payment. - * - */ -data class OfferData( - val defaultOffer: OfferTypes.Offer, - val payerKey: PrivateKey -) \ 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 deleted file mode 100644 index 9b391af0..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ElectrumServers.kt +++ /dev/null @@ -1,184 +0,0 @@ -package fr.acinq.phoenix.data - -import fr.acinq.lightning.io.TcpSocket -import fr.acinq.lightning.utils.ServerAddress - - -private fun electrumServer(host: String, port: Int = 50002): ServerAddress = - ServerAddress(host = host, port = port, tls = TcpSocket.TLS.TRUSTED_CERTIFICATES()) - -private fun electrumServer(host: String, port: Int = 50002, publicKey: String): ServerAddress = - ServerAddress(host = host, port = port, tls = TcpSocket.TLS.PINNED_PUBLIC_KEY(publicKey)) - -private fun electrumServerOnion(host: String, port: Int = 50002): ServerAddress = - ServerAddress(host = host, port = port, tls = TcpSocket.TLS.DISABLED) - -val mainnetElectrumServers = listOf( - electrumServer(host = "electrum.acinq.co"), - electrumServer( - host = "E-X.not.fyi", publicKey = - "MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA8wmp3hyau0aAOjszUUJY" + - "YcMlDqlQ0/Gi7xYf0id1CG+e0yjU2pHuPXgnEmtXdsLIF5GleU7LP5L1xPrzGQD3" + - "LZb8CGKcl7Ve9H167wt5kiehJ/AaF4xcL96uaGQ8ykZMxZrz01AD72mT7u9S7IJt" + - "ypdHbiSq9YiTQj/lscYw318woRV/VLf9qaPfANileffEDRuOJB4OT6FizB+1CDoD" + - "ayI8J7sEiPPYuV7/ttNIEGH6wQCQLxQHQAP6fkAAQ+WMuwl2UeG7NvDocHZJp5hA" + - "L0LJkgquH4LaoFzzA2Yh61Ep1uWeRH7KKlXQnhPRUkgUKfrgovhT5kyszIIpkZiZ" + - "Z2g15fXTRVp2WBxJSa9qPEgg310T37KFXwaV08XdnEEa7pz5oHYUcUPGlRWuYDVQ" + - "X7HAUYvwT84eRvEj+E6L3FhsI0EulzvaHUO8SvKjSK94yoG6FepFi95eNdAwIUXg" + - "LgOWKu/zsdCoDWbaA9nihIJw9ZPESbb8q1WDAOV+M6YLcAyE0hLWDzra3euxUAuB" + - "vIc/tP8RkJ1tzrHE+3KosNAO7y8mP4XlnPvkY5ZS01VXL6a+NoPcDL3+tZr1jjb4" + - "XBjxhaQKn4EhlvwTURL9VOoZADOnVU+DmGiTRJbKNIL84yio/nIubKrD4eONggUh" + - "QDEcaySiP9R+yKz5C9o/WlUCAwEAAQ==" - ), - electrumServer( - host = "btc.cihar.com", publicKey = - "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs0gd2ZsghxUZNwjY6cAD" + - "eZRRvk4sGUvkp5SEENNotiwCFPWXdNxCWxh1aiXpLc/h1+1NmwDHDhFXZDZNGFEW" + - "GPjW92uZWlcGVZffJWqc8XAvVmTKXUgCDv5daEtyTxk/69NDmmDWSeltV8020ykD" + - "FcU5cE/xEmBCfFRoR6yIGwIsCQAIX7XnfbDg1+JdN2N3ZSOOlY4B9r7n3Pm0Q0MW" + - "kRykSFk8EEQYmtk383aFZVDuvUkgLLFsBb0zmkWEVrm6Jy1hXyfWqdsrLaipqhy7" + - "2n62mHT9vfKhTGIoOXR989v6FA+EIYAklIL2ptX3vLqqvOnRjB122b9eT5ZpZhNi" + - "uwIDAQAB" - ), - electrumServer(host = "e.keff.org"), // certificate has expired - electrumServer( - host = "tardis.bauerj.eu", publicKey = - "MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAxLHijYCSNS9SDOgcMPo3" + - "ldVzRTo3LYwozUOUI2P5P8ip7sLFmXPjLbRdzSaKi6YA1J56muie5MJgAimqPo8F" + - "vclOGQrArpU/mQEHbWBZJyPQiftldILRLGAN5OpZnAilLtNuPOtbqbEn5KtX7hyz" + - "K4Xq+RZd32PMpehVVpG9LZTL/QCB5m99iffUl5uR3BX36siOJIWpahPMizzJKdP0" + - "RNZcAKrx5YdWStUYtfprjfBDKXQN6SB5tOVHxVLPpQz3+Iv1mab2nBbQxqiuPTyW" + - "8KC1ZsaLfvnQdBgnWxSPcSuLmlc4hsjloC0oinUnH3j4MvqkaTrTsokUMF4ROHiX" + - "ks9UcbvzdTXHh9c+8Ia/fVLLAIZitKrc9glFKI1hkjFRAQeGlc3m4TFcsT8ue8a6" + - "C6btxW1XZ/BPhznpk9FdUtU0BjZKtwg12cuqSfBcqdFgIwN60jM5N16n1hQDrMHN" + - "eZuW5DcVBIR4gq8eZUZ15460Ck4qufliWFD/M6G7rO+hLOIxu9MEe6r5CF1bGaNw" + - "mNJhIZeg3JGN9fn1h7kX4VW5H+9v7YVpYGB/vGXCDEsOLjHlZVjgbtungBVJ1UML" + - "uOixmccwKWZecT5jXCypj671lzOxF6edUbRsD2xXFjkG5RoifqyNY6zM08bNTyVo" + - "BbQJhHwGCCFBV/e5RgVATIUCAwEAAQ==" - ), - electrumServer( - host = "VPS.hsmiths.com", publicKey = - "MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwaNR7gST8KEAwc7tGTZP" + - "1T6qVhg+KjqY1LpvkZ8m1QBPh+j6HsTjB4+RiH1MerqMWVLD9dEZEYZsEUREySyb" + - "G3szQ1nf+GZkn3XorKyvX29B0aZow3ZdAKwTXF51jwCE/G7j3UpteKIYoX/egoUq" + - "G8k/N3q0jxUZXGEY33BhHGAdiuRAk70QDIUjh1zuPHVED9tE9fc/qeJ/ZlQbQHzK" + - "Y820JcqR1JingTufVps26SyiOHO+jzlRGsO3qHpAmtc1ikEJt1dPS53Rcu8LojF0" + - "kgKZ5oCgGSCWU/+T+8xffp/y5GrV7lxlFNNwM2HFLvx24r2ovYujMzd45VgQ+rIG" + - "V1FcAVDXsarNtSV2R5YmyldScRnzR5IEHcPWPFXMuMvZR31COtD99pXsoS60JO9S" + - "Bd7DMcbU/aXxlyfltla58JS3RV27Uem01YkIfx9d0QW07TXPRmWfQNQ1aZV4Iyfn" + - "oWJnYRR36BOI3TIZKGBSFYvvojNh6noeRVp/BCDRmsi4lL3PLAAH3jKJ2jGjXnZ7" + - "uQNlxsC6ZDL9yezndEjpRYQPYZLrUGOOuNKJReSHtWw83U6wd/5TyI8NRGkHZlCi" + - "585YChjthLYR8fdfHR2sAvMf0pfqBQgV8sNQ3qIZdpaVZIr0wXucsKDGhsZO3Zym" + - "FlzYsP0snim4LMNIlQ+W8N8CAwEAAQ==" - ), - electrumServer( - host = "electrum.qtornado.com", publicKey = - "MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAm78Y1pLds3BzsHpo9Bz2" + - "2lzu9tS/7loMcdL6AVJ3zVgGycI5whOWuaQntG0aYSiHammZNgGzjv44oU/PluzX" + - "PhMxzPlNgSEHnVi2K9mzG7HuGMQh5tEJfvt1zoMmnV4qTaZSLgKKcvrG9112LGQF" + - "UZBhV2J3gN21c8rqj/b6NfEqtItKVU173nqYchYu1GBGrHK1nDWWwTVrtHzY4Qos" + - "etyCPPnj/JWkO6I8kW2CIevpbXh5PROb+YvdbIsqvRODFgo1oLHmwf09Y7ZxE/nS" + - "LZ8yI67U1O39EcRQnoob6pbaqbaoNr0KZSR3xXcJTz2RkhlErMVNH850QGxlXMOr" + - "wNKgVrHhUFEBklbsk11Mx+mYWoeHrvZn55xwpTYGaZmZmAVwwUvherz/Tg490XD2" + - "2+2T78Zu3mmfmHKfD9uhC+ewyn+REHiz9vrvmMeh+YMBOEwf8lp1Jqte7/8xgdfq" + - "kDguOkN6azG64+LyzgWrB79Dfql858Rwn+ezpBZKcSyIL7o1r0T/WeCuR6vLiQ8q" + - "YbwAm61pUM9aVshda2WGRRUkhpy7Uj5OHpEQsnXqtoeEzTuiCr1y19VJgwabcUQH" + - "MVN1HGHiBl5eU4xBhDUG0l/268Ulk2lcRSI0udRtu7jjQzhSnKQL6HUhCm7PCdXg" + - "SqOSwa1yPuHtg/rNBXcqK8cCAwEAAQ==" - ), - electrumServer( - host = "electrum.emzy.de", publicKey = - "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAufqDv0nJICJPoP86wOPY" + - "M/XIfFs6vmVrGEeBZmy9MmMNubulhnyE+sWuFhnIX+0uuFlJ18LJYFOIg0fAaFdN" + - "5xh4vagBNXP9dCcxfVfMGfv9xBQZfWhKrDjC4DCJ82n3K+Q4RqpK/yS9GIZIcqrG" + - "3rxELBZ8NHVPIXveW0PnagGeQ31NDdOAq9MBKiKRcmfKem5daUEo/xM4nTt+tOTx" + - "l5sHicdQSCePHXewMXzmkM/Vw1rMJZKeTwnLX44TsppEi47fXUFcduB2+A1xHQIg" + - "E9wa4Bqc2ZoUtKKBayeeU02C2SBFgVxtAWT6YESdcPP8u+pR7lADA7QZNUVNKMvM" + - "NwIDAQAB" - ), - electrumServer( - host = "ecdsa.net", port = 110, publicKey = - "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzGHtaM37N0tgS7xcWmSq" + - "S2eOHBOtsZIGkkccbQbdmkgSpMGZSJgr23OofrlhmcdzFIzlqK2nXii7+5KW3Lgs" + - "zHKt9pArroFRRRFvoWHp5ijWYRATo7GKVPCngCPbg4fl4wxPYp9yg14BPe7BJfSN" + - "Stz6gV+akcCmKMVfqN5JFqPuuzOmSib270TgHCtIUccgDqHdP1muPQWZjCCxjePT" + - "f6e6J478Bh+Eop4lqvpEiLGeU/6Qj2oZ2tmO7j09J6Ycp0FHBISuCWWUCuZmIEk/" + - "NGOIUagggRU2tVFeW6wjSm1T3Q/z/b6G5oIaldZnklqo//79d3B4Fjj+C8lnFhpP" + - "/wIDAQAB" - ), - electrumServer(host = "electrum.hodlister.co"), - electrumServer(host = "electrum3.hodlister.co"), - electrumServer(host = "electrum5.hodlister.co"), - electrumServer( - host = "fortress.qtornado.com", publicKey = - "MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAy7xI+hVU3rUSAyMMX8OI" + - "LdCs5m5Az1rzFL/8MTSnCBUnXY+YjmN8vFg8ktc/ebwSnjxCfjjXjPPcf0qSS2Xa" + - "WZ3M7nAlzYLpZCTuGBWE2ZbQ30QzXiMzh3Ucum4EJUeGOTEDtisdCrv3jjDs/P96" + - "UgR8MMA1fJqIpxAKnbEj5I9FX9t4eFVhYl6aiykJUFpV3aUZiDsqH8v5MiXCWHAc" + - "EGx1nfzY/os5xYYm/w0+VJiyX6HN0AViA2zF1wnWZFRqwCWpffaZAAPQdYaJ+bGl" + - "zR2PQneczw01EfJwMgb8QfMy3GbZFga7gT5NcZGzEAztL8Y7EzhtjcLksvhm5TB7" + - "iZbvNlCTmyv6F/jm90sZZAwYsg+9Dzl5ciJTKigQnMbPlDaOiRUKXkdw114Q2emO" + - "XKxs74fBl2g/XNO+3Jt7LSDrHEN38ygrHROxPbCTJmUawQ29KUBDULlnsWgzb9Ni" + - "uWMdaPJTXPUQSEYcux1jkVED1l5vANds0bTgM3fipb1MhvFb2GYJiVJkwu342fiG" + - "ADGpTzzjMqhsxevph8zh72MdaxyHCLlvk5OswTEoGNdo6ZFRBpmWDJF9zMnXCPEM" + - "vk2PS8RRlECPNORxSDsu79uXVm0+VmhLshcLOEEb+9qhLid063YLZrwEqZJshbm9" + - "Gcc9FdI7SUP85+RR9XnyL9cCAwEAAQ==" - ), - electrumServer(host = "electrum.blockstream.info"), - electrumServer(host = "blockstream.info", port = 700) -) - -val testnetElectrumServers = listOf( - electrumServer( - host = "testnet.qtornado.com", port = 51002, publicKey = - "MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwkLgqNkkTbwpV3gMdgDA" + - "+jJFdzrOp8vIDT/qxVIox8NZ53pxPc2N44aeY1NJx4TyfpHUGcI7l+gxZfLr8a13" + - "o3CIQSotDbZZdJhS6Ir5tT4iRqwZJch+HayTQf9rztv8OQWgrflWDzCiYtBA5PGx" + - "6LEQWyah/xPPUbeANe/ndEzlfAhXjNcynSfrkikzTgFNBqnc5CcTkHjYgzCXqMwy" + - "ZCD6kQTQG+eqIHSHul21dwUougfCWCR+P0zFA7LeUfPz2mLZktmGXjqTyYZ+0ZTU" + - "gJz/MMZt9PDWGJZsHQzoFSCMicukKtnvZ4Q0gbPOoYp8+WjD4SH+WmC3MZdLagsi" + - "05hUDdm7PHIM1VHQTALLGRnW3yTOaqhsvYGAM5UOkDcmgUqIr6IztHGWCKldfbhS" + - "c4l7BIgvwW2M6FxYlSAcavIodNfvEC1ythdMzl8bZsBjGIOZ39WtiM0grgcg7bb8" + - "W5ovZpLOXpzZBjS0zB0sZJnumjS+3jCSjy9rZXGUn3JmMdqtTV8RQxkB8OBJhFf5" + - "qtMSZXiJIr9RH71VoJKjnds/hoILHuCKU3HOJeo0+4KSD8+q4g3tZLr/haIrsHg5" + - "uifT9db6tDML1PTKpbHkW+f3w9PdhSmsNUUXrgNmQ0MoBhxV7U2Qcug3jX3xaf1P" + - "gwWDg3nZZizhuvBceY0IYLECAwEAAQ==" - ), - electrumServer(host = "blockstream.info", port = 993), - electrumServer( - host = "testnet.aranguren.org", port = 51002, publicKey = - "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA+RL/AH7wn08YKlRCswER" + - "M0JBdGycRuGBgQbM0guzDxi7Ov01WB9AM0DX7GC09pAOefbRP8QzXEhyKO0qpnin" + - "+5Vz4jKS+xv4zPGx2MpTqjJxzom/6v13cumZxWXMzVSeNUTjVp2sOPQ1JQaHqqjs" + - "2lTgShWu+pAgKUH1KPWxMSz21cI+AQkT8NuuXe0USYYIeiXzyTpciIaBf50j6185" + - "u+4bUwA3hvdPZyrkJDtSluJ0HiJzCSFlmNYNHLqbvZNAYrgUM3qJRTsvmD0JK6mm" + - "8m7iXW4m6mKX22VgR93meD/3rdcrJ8FbMbVlkS3wimzcYezls9JytaXupyeRKhQj" + - "TQIDAQAB" - ), -) - -val mainnetElectrumServersOnion: List 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 deleted file mode 100644 index 04693cbf..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/ExchangeRates.kt +++ /dev/null @@ -1,174 +0,0 @@ -package fr.acinq.phoenix.data - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -sealed class ExchangeRate { - - abstract val fiatCurrency: FiatCurrency - abstract val timestampMillis: Long - - /** An exchange rate may be between a fiat currency and Bitcoin, or between a fiat currency and the US Dollar. */ - enum class Type { - BTC, - USD - } - - data class Row( - val fiat: String, - val price: Double, - val type: Type, - val source: String, - val updated_at: Long - ) - - /** - * Price: 1 BTC = $price FIAT - */ - data class BitcoinPriceRate( - override val fiatCurrency: FiatCurrency, - /** The price of 1 BTC in this currency */ - val price: Double, - val source: String, - override val timestampMillis: Long - ): ExchangeRate() { - fun toRow() = Row( - fiat = fiatCurrency.name, - price = price, - type = Type.BTC, - source = source, - updated_at = timestampMillis - ) - } - - /** - * Price: 1 USD = $price FIAT - */ - data class UsdPriceRate( - override val fiatCurrency: FiatCurrency, - /** The price of one US Dollar in this currency */ - val price: Double, - val source: String, - override val timestampMillis: Long - ): ExchangeRate() { - fun toRow() = Row( - fiat = fiatCurrency.name, - price = price, - type = Type.USD, - source = source, - updated_at = timestampMillis - ) - } -} - -/** - * Blockchain.info example: - * { - * "ARS": { - * "15m": 5453151.01, - * "last": 5453151.01, - * "buy": 5453151.01, - * "sell": 5453151.01, - * "symbol": "ARS" - * }, - * "AUD": { - * "15m": 24869.41, - * "last": 24869.41, - * "buy": 24869.41, - * "sell": 24869.41, - * "symbol": "AUD" - * }, - * ... - * } - */ - -@Serializable -data class BlockchainInfoPriceObject( - val last: Double -) - -typealias BlockchainInfoResponse = Map - -/** - * 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 deleted file mode 100644 index 554e831e..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/LocalChannelInfo.kt +++ /dev/null @@ -1,194 +0,0 @@ -/* - * Copyright 2023 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.data - -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.Satoshi -import fr.acinq.bitcoin.TxId -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.channel.states.Aborted -import fr.acinq.lightning.channel.states.ChannelState -import fr.acinq.lightning.channel.states.ChannelStateWithCommitments -import fr.acinq.lightning.channel.states.Closed -import fr.acinq.lightning.channel.states.Closing -import fr.acinq.lightning.channel.states.Normal -import fr.acinq.lightning.channel.states.Syncing -import fr.acinq.lightning.json.JsonSerializers -import fr.acinq.lightning.utils.msat -import fr.acinq.phoenix.managers.PeerManager -import fr.acinq.phoenix.utils.extensions.isTerminated -import fr.acinq.phoenix.utils.extensions.localBalance - -/** - * This class exposes a channel's more important information in an easy-to-consume format. - * - * @param channelId the channel's identifier as a hexadecimal string. - * @param state the channel's state that may contain commitments information. - * @param isBooting if true, this data comes from the local database and the channel has not yet been reestablished - * with the peer. As such, the data may be obsolete (for example, if the peer actually force-closed the channel - * while Phoenix was disconnected). If false, this data is the live view of the channel as negotiated with the - * peer and can be considered up-to-date (but there's no guarantee, as the channel may actually be disconnected!). - */ -data class LocalChannelInfo( - val channelId: String, - val state: ChannelState, - val isBooting: Boolean, -) { - /** True if the channel is terminated and will never be usable again. */ - val isTerminated by lazy { state.isTerminated() } - /** True if the channel can be used to send/receive payments. */ - val isUsable by lazy { state is Normal && !isBooting } - /** True if the channel is `LegacyWaitForFundingConfirmed`, i.e., it may be a zombie channel. */ -// val isLegacyWait by lazy { state.isLegacyWait() } - /** A string version of the state's class. */ - val stateName by lazy { state.stateName } - // FIXME: we should also expose the raw channel's balance, which is what should be used in the channel's details screen, rather than the "smart" spendable balance returned by `localBalance()` - /** The channel's spendable balance, as seen in [ChannelState.localBalance]. */ - val localBalance by lazy { state.localBalance() } - /** - * The channel's receive capacity - should be accurate but still depends on the network feerate. - * - * @return null if the channel is not NORMAL, otherwise the receive capacity. - */ - val availableForReceive by lazy { - when (state) { - is Normal -> state.commitments.availableBalanceForReceive() - else -> null - } - } - /** The channel's current capacity. It actually is the funding capacity of the latest commitment. */ - val currentFundingAmount by lazy { if (state is ChannelStateWithCommitments) state.commitments.latest.fundingAmount else null } - /** A channel may have several active commitments. */ - val commitmentsInfo: List 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 deleted file mode 100644 index c7cc977f..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/MempoolFeerate.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2023 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.data - -import fr.acinq.bitcoin.Satoshi -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.blockchain.fee.FeeratePerByte -import fr.acinq.lightning.blockchain.fee.FeeratePerKw -import fr.acinq.lightning.transactions.Transactions -import fr.acinq.lightning.utils.sat -import fr.acinq.phoenix.managers.NodeParamsManager - - -/** Inspired from https://mempool.space/api/v1/fees/recommended */ -data class MempoolFeerate( - val fastest: FeeratePerByte, - val halfHour: FeeratePerByte, - val hour: FeeratePerByte, - val economy: FeeratePerByte, - val minimum: FeeratePerByte, - val timestamp: Long, -) { - /** - * Estimates roughly the cost of a dual-funded splice, using the current feerate and an arbitrary tx weight. - * - * An additional service fee is expected if there's no channels already. - */ - fun swapEstimationFee(hasNoChannels: Boolean): Satoshi { - return Transactions.weight2fee(feerate = FeeratePerKw(hour), weight = DualFundingPayToSpliceWeight) + if (hasNoChannels) 1000.sat else 0.sat - } - - fun payToOpenEstimationFee(amount: MilliSatoshi, hasNoChannels: Boolean): Satoshi { - return swapEstimationFee(hasNoChannels) + (amount * NodeParamsManager.payToOpenFeeBase / 10_000).truncateToSatoshi() - } - - companion object { - /** Spending a channel output and adding funds from a wpkh wallet with one change output: 2-inputs (wpkh+wsh)/2-outputs (wpkh+wsh) */ - const val DualFundingPayToSpliceWeight = 992 - } -} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/Notification.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/Notification.kt deleted file mode 100644 index 53b9ce9e..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/Notification.kt +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright 2023 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.data - -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.Satoshi -import fr.acinq.lightning.LiquidityEvents -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.utils.UUID -import fr.acinq.lightning.utils.currentTimestampMillis - -/** Notification object, typically regarding missed payments. */ -sealed class Notification { - abstract val id: UUID - abstract val createdAt: Long - abstract val readAt: Long? - val hasBeenRead by lazy { readAt != null } - - sealed class PaymentRejected : Notification() { - abstract val amount: MilliSatoshi - abstract val source: LiquidityEvents.Source - } - - data class OverAbsoluteFee( - override val id: UUID, - override val createdAt: Long, - override val readAt: Long?, - override val amount: MilliSatoshi, - val fee: MilliSatoshi, - override val source: LiquidityEvents.Source, - val maxAbsoluteFee: Satoshi, - ) : PaymentRejected() - - data class OverRelativeFee( - override val id: UUID, - override val createdAt: Long, - override val readAt: Long?, - override val amount: MilliSatoshi, - val fee: MilliSatoshi, - override val source: LiquidityEvents.Source, - val maxRelativeFeeBasisPoints: Int, - ) : PaymentRejected() - - data class FeePolicyDisabled( - override val id: UUID, - override val createdAt: Long, - override val readAt: Long?, - override val amount: MilliSatoshi, - override val source: LiquidityEvents.Source, - ) : PaymentRejected() - - data class MissingOffChainAmountTooLow( - override val id: UUID, - override val createdAt: Long, - override val readAt: Long?, - override val amount: MilliSatoshi, - override val source: LiquidityEvents.Source, - ) : PaymentRejected() - - data class GenericError( - override val id: UUID, - override val createdAt: Long, - override val readAt: Long?, - override val amount: MilliSatoshi, - override val source: LiquidityEvents.Source, - ) : PaymentRejected() -} - -sealed class WatchTowerOutcome : Notification() { - data class Unknown(override val id: UUID, override val createdAt: Long, override val readAt: Long?): WatchTowerOutcome() { - constructor() : this(UUID.randomUUID(), currentTimestampMillis(), null) - } - data class Nominal(override val id: UUID, override val createdAt: Long, override val readAt: Long?, val channelsWatchedCount: Int): WatchTowerOutcome() { - constructor(channelsWatchedCount: Int) : this(UUID.randomUUID(), currentTimestampMillis(), null, channelsWatchedCount) - } - data class RevokedFound(override val id: UUID, override val createdAt: Long, override val readAt: Long?, val channels: Set): 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 deleted file mode 100644 index 958eb816..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/StartBusinessResult.kt +++ /dev/null @@ -1,12 +0,0 @@ -package fr.acinq.phoenix.data - -import fr.acinq.phoenix.PhoenixBusiness -import fr.acinq.phoenix.managers.WalletManager - -sealed class StartBusinessResult { - data class Success(val walletInfo: WalletManager.WalletInfo, val business: PhoenixBusiness): StartBusinessResult() - sealed class Failure : StartBusinessResult() { - data class Generic(val cause: Throwable): Failure() - data object LoadWalletError: Failure() - } -} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/UserTheme.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/UserTheme.kt deleted file mode 100644 index 7a82050e..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/UserTheme.kt +++ /dev/null @@ -1,14 +0,0 @@ -package fr.acinq.phoenix.data - -enum class UserTheme { - LIGHT, DARK, SYSTEM; - - companion object { - fun safeValueOf(value: String?): UserTheme = when (value) { - LIGHT.name -> LIGHT - DARK.name -> DARK - SYSTEM.name -> SYSTEM - else -> SYSTEM - } - } -} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/Wallet.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/Wallet.kt deleted file mode 100644 index 302bf0b1..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/Wallet.kt +++ /dev/null @@ -1,55 +0,0 @@ -package fr.acinq.phoenix.data - -import fr.acinq.bitcoin.PublicKey -import fr.acinq.bitcoin.byteVector -import fr.acinq.phoenix.PhoenixBusiness -import fr.acinq.phoenix.utils.preferences.InternalPrefs -import fr.acinq.phoenix.utils.preferences.UserPrefs -import kotlinx.serialization.Serializable - - -sealed class ListWalletState { - data object Init: ListWalletState() - data object Success: ListWalletState() - sealed class Error: ListWalletState() { - data class Generic(val cause: Throwable?): Error() - data object Serialization: Error() - - sealed class DecryptionError : Error() { - data class GeneralException(val cause: Throwable): DecryptionError() - data class KeystoreFailure(val cause: Throwable): DecryptionError() - } - } -} - -sealed class BaseWalletId -data object EmptyWalletId: BaseWalletId() -/** Wraps a nodeIdHash (hash160 of a nodeId). Easier to maintain and upgrade than a plain String. */ -@Serializable -data class WalletId(val nodeIdHash: String): BaseWalletId() { - constructor(nodeId: PublicKey) : this( - nodeIdHash = nodeId.hash160().byteVector().toHex() - ) - override fun toString() = nodeIdHash - override fun hashCode() = nodeIdHash.hashCode() - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is WalletId) return false - return nodeIdHash == other.nodeIdHash - } -} - -data class UserWallet( - val walletId: WalletId, - val nodeId: String, - val words: List, -) { - 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 deleted file mode 100644 index 2156fc0a..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/WalletContext.kt +++ /dev/null @@ -1,8 +0,0 @@ -package fr.acinq.phoenix.data - -/** Contains contextual information for the wallet, fetched from https://acinq.co/phoenix/walletcontext.json. */ -data class WalletContext( - val isMempoolFull: Boolean, - val androidLatestVersion: Int, - val androidLatestCriticalVersion: Int, -) diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/WalletNotice.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/WalletNotice.kt deleted file mode 100644 index 2372d655..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/WalletNotice.kt +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 1b05221c..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/WalletPayment.kt +++ /dev/null @@ -1,152 +0,0 @@ -package fr.acinq.phoenix.data - -import androidx.compose.runtime.Composable -import fr.acinq.lightning.db.AutomaticLiquidityPurchasePayment -import fr.acinq.lightning.db.Bolt11IncomingPayment -import fr.acinq.lightning.db.Bolt12IncomingPayment -import fr.acinq.lightning.db.ChannelCloseOutgoingPayment -import fr.acinq.lightning.db.IncomingPayment -import fr.acinq.lightning.db.LegacyPayToOpenIncomingPayment -import fr.acinq.lightning.db.LegacySwapInIncomingPayment -import fr.acinq.lightning.db.LightningOutgoingPayment -import fr.acinq.lightning.db.ManualLiquidityPurchasePayment -import fr.acinq.lightning.db.OnChainIncomingPayment -import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment -import fr.acinq.lightning.db.SpliceOutgoingPayment -import fr.acinq.lightning.db.WalletPayment -import fr.acinq.phoenix.data.lnurl.LnurlPay -import fr.acinq.phoenix.utils.converters.AmountFormatter.toPrettyString -import fr.acinq.phoenix.utils.extensions.desc -import org.jetbrains.compose.resources.stringResource -import mantra.composeapp.generated.resources.Res -import mantra.composeapp.generated.resources.paymentdetails_desc_closing_channel -import mantra.composeapp.generated.resources.paymentdetails_desc_cpfp -import mantra.composeapp.generated.resources.paymentdetails_desc_liquidity_automated -import mantra.composeapp.generated.resources.paymentdetails_desc_liquidity_manual -import mantra.composeapp.generated.resources.paymentdetails_desc_splice_out -import mantra.composeapp.generated.resources.paymentdetails_desc_swapin -import mantra.composeapp.generated.resources.paymentdetails_desc_swapout - -/** - * Represents a payment & its associated metadata. - */ -data class WalletPaymentInfo( - val payment: WalletPayment, - val metadata: WalletPaymentMetadata, - val contact: ContactInfo? -) { - val id get() = payment.id -} - -/** Returns true if the payment is a channel-close made by the legacy app to the node's swap-in address. */ -fun WalletPayment.isLegacyMigration(metadata: WalletPaymentMetadata): Boolean? { - return when { - this !is ChannelCloseOutgoingPayment -> false - metadata.userDescription == "kmp-migration-override" -> true - else -> false - } -} - -/** - * Returns a trimmed, localized description of the payment, based on the type and information available. May be null! - * - * For example, a payment closing a channel has no description, and it's up to us to create one. Others like a LN - * payment with an invoice do have a description baked in, and that's what is returned. - */ -@Composable -fun WalletPayment.smartDescription(): String? = when (this) { - is LightningOutgoingPayment -> smartDescription() - is IncomingPayment -> smartDescription() - is ChannelCloseOutgoingPayment -> smartDescription() - is SpliceOutgoingPayment -> smartDescription() - is SpliceCpfpOutgoingPayment -> smartDescription() - is ManualLiquidityPurchasePayment -> smartDescription() - is AutomaticLiquidityPurchasePayment -> smartDescription() -} - - -@Composable -fun LightningOutgoingPayment.smartDescription(): String? = when (val details = this.details) { - is LightningOutgoingPayment.Details.Normal -> details.paymentRequest.desc - is LightningOutgoingPayment.Details.SwapOut -> stringResource(Res.string.paymentdetails_desc_swapout, details.address) - is LightningOutgoingPayment.Details.Blinded -> details.paymentRequest.description -}?.takeIf { it.isNotBlank() } - -@Composable -fun SpliceOutgoingPayment.smartDescription(): String = stringResource(Res.string.paymentdetails_desc_splice_out) - -@Composable -fun SpliceCpfpOutgoingPayment.smartDescription(): String = stringResource(Res.string.paymentdetails_desc_cpfp) - -@Composable -fun ChannelCloseOutgoingPayment.smartDescription(): String = stringResource(Res.string.paymentdetails_desc_closing_channel) - -@Composable -fun ManualLiquidityPurchasePayment.smartDescription(): String = - stringResource( - Res.string.paymentdetails_desc_liquidity_manual, - liquidityPurchase.amount.toPrettyString(BitcoinUnit.Sat, withUnit = true) - ) - -@Composable -fun AutomaticLiquidityPurchasePayment.smartDescription(): String = - stringResource( - Res.string.paymentdetails_desc_liquidity_automated, - liquidityPurchase.amount.toPrettyString(BitcoinUnit.Sat, withUnit = true) - ) - -@Suppress("DEPRECATION") -@Composable -fun IncomingPayment.smartDescription() : String? = when (this) { - is Bolt11IncomingPayment -> paymentRequest.description - is Bolt12IncomingPayment -> null - is OnChainIncomingPayment -> stringResource(Res.string.paymentdetails_desc_swapin) - is LegacySwapInIncomingPayment -> stringResource(Res.string.paymentdetails_desc_swapin) - is LegacyPayToOpenIncomingPayment -> when (val origin = origin) { - is LegacyPayToOpenIncomingPayment.Origin.Invoice -> origin.paymentRequest.description - is LegacyPayToOpenIncomingPayment.Origin.Offer -> null - } -}?.takeIf { it.isNotBlank() } - - - -@Suppress("DEPRECATION") -fun WalletPayment.basicDescription(): String? = when (this) { - is Bolt11IncomingPayment -> paymentRequest.description?.takeIf { it.isNotBlank() } - is LegacyPayToOpenIncomingPayment -> when (val origin = origin) { - is LegacyPayToOpenIncomingPayment.Origin.Invoice -> origin.paymentRequest.description - is LegacyPayToOpenIncomingPayment.Origin.Offer -> null - } - is IncomingPayment -> null - is LightningOutgoingPayment -> when (val details = this.details) { - is LightningOutgoingPayment.Details.Normal -> details.paymentRequest.desc - is LightningOutgoingPayment.Details.SwapOut -> null - is LightningOutgoingPayment.Details.Blinded -> details.paymentRequest.description - } - is ChannelCloseOutgoingPayment -> null - is SpliceOutgoingPayment -> null - is SpliceCpfpOutgoingPayment -> null - is ManualLiquidityPurchasePayment -> null - is AutomaticLiquidityPurchasePayment -> null -}?.takeIf { it.isNotBlank() } - - -/** - * Represents information from the `payments_metadata` table. - */ -data class WalletPaymentMetadata( - val lnurl: LnurlPayMetadata? = null, - val originalFiat: ExchangeRate.BitcoinPriceRate? = null, - val userDescription: String? = null, - val userNotes: String? = null, - val lightningAddress: String? = null, - val modifiedAt: Long? = null -) - -data class LnurlPayMetadata( - val pay: LnurlPay.Intent, - val description: String, - val successAction: LnurlPay.Invoice.SuccessAction? -) { - companion object { /* allow companion extensions */ } -} 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 deleted file mode 100644 index 622984b4..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/Lnurl.kt +++ /dev/null @@ -1,264 +0,0 @@ -/* - * Copyright 2021 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.data.lnurl - -import co.touchlab.kermit.Logger -import fr.acinq.bitcoin.Bech32 -import fr.acinq.lightning.logging.debug -import fr.acinq.lightning.logging.error -import fr.acinq.lightning.logging.info -import fr.acinq.lightning.utils.msat -import fr.acinq.phoenix.utils.Parser -import io.ktor.client.statement.HttpResponse -import io.ktor.client.statement.bodyAsText -import io.ktor.client.statement.request -import io.ktor.http.URLBuilder -import io.ktor.http.URLProtocol -import io.ktor.http.Url -import io.ktor.http.encodedPath -import io.ktor.http.isSecure -import io.ktor.http.isSuccess -import io.ktor.utils.io.charsets.Charsets -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.doubleOrNull -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.long -import kotlinx.serialization.json.longOrNull - -/** - * This class describes the various types of Lnurls supported by phoenix: - * - auth - * - pay - * - withdraw - * - * It also contains the possible errors related to the Lnurl flow: - * errors that break the specs, or errors raised when the data returned - * by the Lnurl service are not valid. - * - * A companion object contains the utility methods that parse the urls, read a response - * from a Lnurl service, and transform this response into a valid Lnurl object. - */ -sealed interface Lnurl { - - /** - * Some lnurls must be executed to be of any use, they don't contain any info by themselves. Those - * lnurls are usually not visible to the user and are called immediately. - */ - data class Request(override val initialUrl: Url, val tag: Tag?) : Lnurl - - /** - * Qualified lnurls objects contain all the necessary data needed from the lnurl service for the user - * to decide how to proceed. - */ - sealed interface Qualified : Lnurl - - val initialUrl: Url - - enum class Tag(val label: String) { - Auth("login"), - Withdraw("withdrawRequest"), - Pay("payRequest") - } - - companion object { - internal val format: Json = Json { ignoreUnknownKeys = true } - - /** - * Attempts to extract a [Lnurl] from a string. - * - * @param source can be a bech32 lnurl, a non-bech32 lnurl, or a lightning address. - * @return a [LnurlAuth] if the source is a login lnurl, or an [Url] if it is a payment/withdrawal lnurl. - * - * Throws an exception if the source is malformed or invalid. - */ - fun extractLnurl(source: String, logger: Logger): Lnurl { - val input = Parser.trimMatchingPrefix(source, Parser.lightningPrefixes + Parser.bitcoinPrefixes + Parser.lnurlPrefixes) - val url: Url = try { - logger.debug { "parsing as lnurl source=$source" } - parseBech32Url(input) - } catch (bech32Ex: Exception) { - try { - if (lud17Schemes.any { input.startsWith(it, ignoreCase = true) }) { - parseNonBech32Lud17(input, logger) - } else { - parseNonBech32Http(input) - } - } catch (nonBech32Ex: Exception) { - logger.info { "cannot parse source as non-bech32 lnurl: ${nonBech32Ex.message ?: nonBech32Ex::class} or as a bech32 lnurl: ${bech32Ex.message ?: bech32Ex::class}" } - throw LnurlError.Invalid(cause = nonBech32Ex) - } - } - val tag = url.parameters["tag"]?.let { - when (it) { - Tag.Auth.label -> Tag.Auth - Tag.Withdraw.label -> Tag.Withdraw - Tag.Pay.label -> Tag.Pay - else -> null // ignore unknown tags and handle the lnurl as a `request` to be executed immediately - } - } - return when (tag) { - Tag.Auth -> { - val k1 = url.parameters["k1"] - if (k1.isNullOrBlank()) { - throw LnurlError.Auth.MissingK1() - } else { - LnurlAuth(url, k1) - } - } - else -> Request(url, tag) - } - } - - /** Lnurls are originally bech32 encoded. If unreadable, throw an exception. */ - private fun parseBech32Url(source: String): Url { - val (_, data) = Bech32.decode(source) - val payload = Bech32.five2eight(data, 0).decodeToString() - val url = URLBuilder(payload).build() - if (!url.protocol.isSecure()) throw LnurlError.UnsafeResource() - return url - } - - /** Lnurls sometimes hide in regular http urls, under the lightning parameter. */ - private fun parseNonBech32Http(source: String): Url { - val urlBuilder = URLBuilder(source) - val lightningParam = urlBuilder.parameters["lightning"] - return if (!lightningParam.isNullOrBlank()) { - // this url contains a lnurl fallback which takes priority - and must be bech32 encoded - parseBech32Url(lightningParam) - } else { - if (!urlBuilder.protocol.isSecure()) throw LnurlError.UnsafeResource() - urlBuilder.build() - } - } - - private val lud17Schemes = listOf( - "phoenix:lnurlp://", "phoenix:lnurlp:", - "lnurlp://", "lnurlp:", - "phoenix:lnurlw://", "phoenix:lnurlw:", - "lnurlw://", "lnurlw:", - "phoenix:keyauth://", "phoenix:keyauth:", - "keyauth://", "keyauth:", - ) - - /** Converts LUD-17 lnurls (using a custom lnurl scheme like lnurlc, lnurlp, keyauth) into a regular http url. */ - private fun parseNonBech32Lud17(source: String, logger: Logger): Url { - val matchingPrefix = lud17Schemes.firstOrNull { source.startsWith(it, ignoreCase = true) } - val stripped = if (matchingPrefix != null) { - source.drop(matchingPrefix.length) - } else { - throw IllegalArgumentException("source does not use a lud17 scheme: $source") - } - logger.debug { "lud-17 scheme found - transforming input into an http request" } - return URLBuilder(stripped).apply { - encodedPath.split("/", ignoreCase = true, limit = 2).let { - this.host = it.first() - this.encodedPath = "/${it.drop(1).joinToString()}" - } - protocol = if (this.host.endsWith(".onion")) { - URLProtocol.HTTP - } else { - URLProtocol.HTTPS - } - }.build() - } - - /** - * Processes a HTTP response replied by a lnurl service and returns a [JsonObject]. - * - * Throw: - * - [LnurlError.RemoteFailure.Code] if service returns a non-2XX code - * - [LnurlError.RemoteFailure.Unreadable] if response is not valid JSON - * - [LnurlError.RemoteFailure.Detailed] if service reports an internal error message (`{ status: "error", reason: "..." }`) - */ - suspend fun processLnurlResponse(response: HttpResponse, logger: Logger): JsonObject { - val url = response.request.url - val json: JsonObject = try { - // From the LUD-01 specs: - // > HTTP Status Codes and Content-Type: - // > Neither status codes or any HTTP Header has any meaning. Servers may use - // > whatever they want. Clients should ignore them [...] and just parse the - // > response body as JSON, then interpret it accordingly. - Json.decodeFromString(response.bodyAsText(Charsets.UTF_8)) - } catch (e: Exception) { - logger.error { "unhandled response from url=$url: ${e.message}" } - throw LnurlError.RemoteFailure.Unreadable(url.host) - } - - logger.debug { "lnurl service=${url.host} returned response=${json.toString().take(100)}" } - return if (json["status"]?.jsonPrimitive?.content?.trim()?.equals("error", true) == true) { - val errorMessage = json["reason"]?.jsonPrimitive?.content?.trim() ?: "" - if (errorMessage.isNotEmpty()) { - logger.error { "lnurl service=${url.host} returned error=$errorMessage" } - throw LnurlError.RemoteFailure.Detailed(url.host, errorMessage.take(90).replace("<", "")) - } else if (!response.status.isSuccess()) { - throw LnurlError.RemoteFailure.Code(url.host, response.status) - } else { - throw LnurlError.RemoteFailure.Unreadable(url.host) - } - } else { - json - } - } - - /** Converts a lnurl JSON response to a [Lnurl] object. */ - fun parseLnurlJson(url: Url, json: JsonObject): Lnurl { - val callback = URLBuilder(json["callback"]?.jsonPrimitive?.content ?: throw LnurlError.MissingCallback()).build() - if (!callback.protocol.isSecure()) throw LnurlError.UnsafeResource() - val tag = json["tag"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() } ?: throw LnurlError.NoTag() - return when (tag) { - Tag.Withdraw.label -> { - val k1 = json["k1"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() } ?: throw LnurlError.Withdraw.MissingK1() - val minWithdrawable = json["minWithdrawable"]?.jsonPrimitive?.doubleOrNull?.takeIf { it > 0f }?.toLong()?.msat - ?: json["minWithdrawable"]?.jsonPrimitive?.long?.takeIf { it > 0 }?.msat - ?: 0.msat - val maxWithdrawable = json["maxWithdrawable"]?.jsonPrimitive?.doubleOrNull?.takeIf { it > 0f }?.toLong()?.msat - ?: json["maxWithdrawable"]?.jsonPrimitive?.long?.takeIf { it > 0 }?.msat - ?: minWithdrawable - val dDesc = json["defaultDescription"]?.jsonPrimitive?.content ?: "" - LnurlWithdraw( - initialUrl = url, - callback = callback, - k1 = k1, - defaultDescription = dDesc, - minWithdrawable = minWithdrawable.coerceAtMost(maxWithdrawable), - maxWithdrawable = maxWithdrawable - ) - } - Tag.Pay.label -> { - val minSendable = json["minSendable"]?.jsonPrimitive?.doubleOrNull?.takeIf { it > 0f }?.toLong()?.msat - ?: json["minSendable"]?.jsonPrimitive?.longOrNull?.takeIf { it > 0 }?.msat - ?: throw LnurlError.Pay.Intent.InvalidMin() - val maxSendable = json["maxSendable"]?.jsonPrimitive?.doubleOrNull?.takeIf { it > 0f }?.toLong()?.msat - ?: json["maxSendable"]?.jsonPrimitive?.longOrNull?.coerceAtLeast(minSendable.msat)?.msat - ?: throw LnurlError.Pay.Intent.MissingMax() - val metadata = LnurlPay.parseMetadata(json["metadata"]?.jsonPrimitive?.content ?: throw LnurlError.Pay.Intent.MissingMetadata()) - val maxCommentLength = json["commentAllowed"]?.jsonPrimitive?.longOrNull?.takeIf { it > 0 } - LnurlPay.Intent( - initialUrl = url, - callback = callback, - minSendable = minSendable, - maxSendable = maxSendable, - metadata = metadata, - maxCommentLength = maxCommentLength - ) - } - else -> throw LnurlError.UnhandledTag(tag) - } - } - } -} 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 deleted file mode 100644 index 89cb99f0..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlAuth.kt +++ /dev/null @@ -1,177 +0,0 @@ -/* - * Copyright 2022 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.data.lnurl - -import fr.acinq.bitcoin.ByteVector -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.Crypto -import fr.acinq.bitcoin.DeterministicWallet -import fr.acinq.bitcoin.KeyPath -import fr.acinq.bitcoin.PrivateKey -import fr.acinq.bitcoin.PublicKey -import fr.acinq.bitcoin.crypto.Digest -import fr.acinq.bitcoin.crypto.Pack -import fr.acinq.bitcoin.crypto.hmac -import fr.acinq.lightning.crypto.LocalKeyManager -import io.ktor.http.Url - -data class LnurlAuth( - override val initialUrl: Url, - val k1: String -) : Lnurl.Qualified { - - enum class Action { - Register, Login, Link, Auth - } - - val action = initialUrl.parameters["action"]?.let { action -> - when (action.lowercase()) { - "register" -> Action.Register - "login" -> Action.Login - "link" -> Action.Link - "auth" -> Action.Auth - else -> null - } - } - - /** - * Early versions of our lnurl-auth implementation used non-standard keys on Android. We define a legacy-friendly - * scheme to let the user switch to that old behaviour when needed. The default scheme is the recommended - * one and is compliant with the specifications. - * - * Note: this does **not** affect the [LnurlAuth.LegacyDomain.filterDomain] method for the key derivation path, - * which will apply in both cases. - */ - sealed class Scheme(val id: Int) { - /** - * The default scheme is spec compliant and that's what should be used on new service. The hashing key and - * the linking key are computed by deriving the master key. The iOS app should always use that scheme. - */ - data object DEFAULT_SCHEME : Scheme(0) - - /** - * This is the scheme used by the legacy android wallet. The hashing key is derived from the node key, and - * the linking key derived from the hashing key. Only use this when needed. - */ - data object ANDROID_LEGACY_SCHEME : Scheme(1) - } - - companion object { - - /** Signs the challenge with the key provided and returns the public key and the DER-encoded signed data. */ - fun signChallenge( - challenge: String, - key: PrivateKey - ): Pair { - 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 deleted file mode 100644 index 83d1bf48..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlError.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2022 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.data.lnurl - -import io.ktor.http.HttpStatusCode - -sealed class LnurlError(override val message: String? = null) : RuntimeException(message) { - val details: String by lazy { "Lnurl error=${message ?: this::class.simpleName ?: "N/A"} in url=" } - - sealed class Auth(override val message: String?) : LnurlError(message) { - class MissingK1 : Auth("missing k1 parameter") - } - - sealed class Withdraw(override val message: String?) : LnurlError(message) { - class MissingK1 : Withdraw("missing k1 parameter in auth metadata") - } - - sealed class Pay : LnurlError() { - sealed class Intent(override val message: String?) : LnurlError(message) { - class InvalidMin : Intent("invalid minimum amount") - class MissingMax : Intent("missing maximum amount parameter") - class MissingMetadata : Intent("missing metadata parameter") - data class InvalidMetadata(val meta: String) : Intent("invalid meta=$meta") - } - - sealed class Invoice(override val message: String?) : LnurlError(message) { - abstract val origin: String - - data class Malformed( - override val origin: String, - val context: String - ) : Invoice("malformed: $context") - - data class InvalidAmount(override val origin: String) : Invoice("paymentRequest.amount doesn't match user input") - } - } - - data class Invalid(override val cause: Throwable?) : LnurlError("cannot be parsed as a bech32 or as a human readable lnurl") - class NoTag : LnurlError("no tag field found") - data class UnhandledTag(val tag: String) : LnurlError("unhandled tag=$tag") - class UnsafeResource : LnurlError("resource should be https") - class MissingCallback : LnurlError("missing callback in metadata response") - - sealed class RemoteFailure(override val message: String) : LnurlError(message) { - abstract val origin: String - - data class IsWebsite(override val origin: String) : RemoteFailure("this appears to just be a website") - data class LightningAddressError(override val origin: String) : RemoteFailure("service $origin doesn't support lightning addresses, or doesn't know this user") - data class CouldNotConnect(override val origin: String) : RemoteFailure("could not connect to $origin") - data class Unreadable(override val origin: String) : RemoteFailure("unreadable response from $origin") - data class Detailed(override val origin: String, val reason: String) : RemoteFailure("error=$reason from $origin") - data class Code(override val origin: String, val code: HttpStatusCode) : RemoteFailure("error code=$code from $origin") - } -} 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 deleted file mode 100644 index 46df0960..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlPay.kt +++ /dev/null @@ -1,226 +0,0 @@ -/* - * Copyright 2022 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.data.lnurl - -import co.touchlab.kermit.Logger -import fr.acinq.bitcoin.ByteVector -import fr.acinq.bitcoin.utils.Try -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.payment.Bolt11Invoice -import fr.acinq.phoenix.data.lnurl.Lnurl.Companion.format -import fr.acinq.phoenix.db.cloud.b64Decode -import io.ktor.http.Url -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive - -sealed class LnurlPay : Lnurl.Qualified { - - /** - * Response from a lnurl service to describe what kind of payment is expected. - * First step of the lnurl-pay flow. - */ - data class Intent( - override val initialUrl: Url, - val callback: Url, - val minSendable: MilliSatoshi, - val maxSendable: MilliSatoshi, - val metadata: Metadata, - val maxCommentLength: Long? - ) : LnurlPay() { - data class Metadata( - val raw: String, - val plainText: String, - val longDesc: String?, - val imagePng: String?, // base64 encoded png - val imageJpg: String?, // base64 encoded jpg - val identifier: String?, - val email: String?, - val unknown: JsonArray? - ) { - val lnid: String? by lazy { email ?: identifier } - - override fun toString(): String { - return "Metadata(plainText=$plainText, longDesc=${longDesc?.take(50)}, identifier=$identifier, email=$email, imagePng=${imagePng?.take(10)}, imageJpg=${imageJpg?.take(10)})" - } - } - - override fun toString(): String { - return "Intent(minSendable=$minSendable, maxSendable=$maxSendable, metadata=$metadata, maxCommentLength=$maxCommentLength, initialUrl=$initialUrl, callback=$callback)".take(100) - } - } - - /** - * Invoice returned by a lnurl service after user states what they want to pay. - * Second step of the lnurl-payment flow. - */ - data class Invoice( - override val initialUrl: Url, - val invoice: Bolt11Invoice, - val successAction: SuccessAction? - ) : LnurlPay() { - sealed class SuccessAction { - data class Message( - val message: String - ) : SuccessAction() - - data class Url( - val description: String, - val url: io.ktor.http.Url - ) : SuccessAction() - - data class Aes( - val description: String, - val ciphertext: ByteVector, - val iv: ByteVector - ) : SuccessAction() { - data class Decrypted( - val description: String, - val plaintext: String - ) - } - - enum class Tag(val label: String) { - Message("message"), - Url("url"), - Aes("aes") - } - } - } - - - companion object { - - /** Parses json into a [LnurlPay.Invoice] object. Throws an [LnurlError.PayInvoice] exception if unreadable. */ - fun parseLnurlPayInvoice( - intent: Intent, - origin: String, - json: JsonObject - ): Invoice { - try { - val pr = json["pr"]?.jsonPrimitive?.content ?: throw LnurlError.Pay.Invoice.Malformed(origin, "missing pr") - val invoice = when (val res = Bolt11Invoice.read(pr)) { - is Try.Success -> res.result - is Try.Failure -> throw LnurlError.Pay.Invoice.Malformed(origin, res.error.message ?: res.error::class.toString()) - } - - val successAction = parseSuccessAction(origin, json) - return Invoice(intent.initialUrl, invoice, successAction) - } catch (t: Throwable) { - when (t) { - is LnurlError.Pay.Invoice -> throw t - else -> throw LnurlError.Pay.Invoice.Malformed(origin, "unknown error") - } - } - } - - private fun parseSuccessAction( - origin: String, - json: JsonObject - ): Invoice.SuccessAction? { - val obj = try { - json["successAction"]?.jsonObject // throws on Non-JsonObject (e.g. JsonNull) - } catch (t: Throwable) { - null - } ?: return null - - return when (obj["tag"]?.jsonPrimitive?.content) { - Invoice.SuccessAction.Tag.Message.label -> { - val message = obj["message"]?.jsonPrimitive?.content ?: return null - if (message.isBlank() || message.length > 144) { - throw LnurlError.Pay.Invoice.Malformed(origin, "success.message: bad length") - } - Invoice.SuccessAction.Message(message) - } - Invoice.SuccessAction.Tag.Url.label -> { - val description = obj["description"]?.jsonPrimitive?.content ?: return null - if (description.length > 144) { - throw LnurlError.Pay.Invoice.Malformed(origin, "success.url.description: bad length") - } - val urlStr = obj["url"]?.jsonPrimitive?.content ?: return null - val url = Url(urlStr) - Invoice.SuccessAction.Url(description, url) - } - Invoice.SuccessAction.Tag.Aes.label -> { - val description = obj["description"]?.jsonPrimitive?.content ?: return null - if (description.length > 144) { - throw LnurlError.Pay.Invoice.Malformed(origin, "success.aes.description: bad length") - } - val ciphertextStr = obj["ciphertext"]?.jsonPrimitive?.content ?: return null - val ciphertext = ByteVector(ciphertextStr.b64Decode()) - if (ciphertext.size() > (4 * 1024)) { - throw LnurlError.Pay.Invoice.Malformed(origin, "success.aes.ciphertext: bad length") - } - val ivStr = obj["iv"]?.jsonPrimitive?.content ?: return null - if (ivStr.length != 24) { - throw LnurlError.Pay.Invoice.Malformed(origin, "success.aes.iv: bad length") - } - val iv = ByteVector(ivStr.b64Decode()) - Invoice.SuccessAction.Aes(description, ciphertext = ciphertext, iv = iv) - } - else -> null - } - } - - /** Decode a serialized [Lnurl.Pay.Metadata] object. */ - fun parseMetadata(raw: String): Intent.Metadata = try { - val array = format.decodeFromString(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}" } - } - } - 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 deleted file mode 100644 index 2cced093..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/data/lnurl/LnurlWithdraw.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2022 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.data.lnurl - -import fr.acinq.lightning.MilliSatoshi -import io.ktor.http.Url - -data class LnurlWithdraw( - override val initialUrl: Url, - val callback: Url, - val k1: String, - val defaultDescription: String, - val minWithdrawable: MilliSatoshi, - val maxWithdrawable: MilliSatoshi -) : Lnurl.Qualified { - override fun toString(): String { - return "LnurlWithdraw(defaultDescription='$defaultDescription', minWithdrawable=$minWithdrawable, maxWithdrawable=$maxWithdrawable, initialUrl=$initialUrl, callback=$callback)".take(100) - } -} diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbFactory.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbFactory.kt deleted file mode 100644 index 6ddaa136..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbFactory.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2020 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.db - -import app.cash.sqldelight.db.SqlDriver -import fr.acinq.phoenix.utils.PlatformContext - -expect fun createChannelsDbDriver(ctx: PlatformContext, fileName: String): SqlDriver - -expect fun createPaymentsDbDriver(ctx: PlatformContext, fileName: String, onError: (String) -> Unit): SqlDriver - -expect fun createAppDbDriver(ctx: PlatformContext): SqlDriver diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbHooks.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbHooks.kt deleted file mode 100644 index e82fbf8a..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbHooks.kt +++ /dev/null @@ -1,52 +0,0 @@ -package fr.acinq.phoenix.db - -import fr.acinq.lightning.utils.UUID -import fr.acinq.phoenix.db.payments.CloudKitInterface -import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase - -/** - * Implement this function to execute platform specific code when a payment is saved to the database. - * For example, on iOS this is used to enqueue the (encrypted) payment for upload to CloudKit. - * - * This function is invoked inside the same transaction used to add/modify the row. - * This means any database operations performed in this function are atomic, - * with respect to the referenced row. - */ -expect fun didSaveWalletPayment(id: UUID, database: PaymentsDatabase) - -/** - * Implement this function to execute platform specific code when a payment is deleted. - * For example, on iOS this is used to enqueue an operation to delete the payment from CloudKit. - */ -expect fun didDeleteWalletPayment(id: UUID, database: PaymentsDatabase) - -/** - * Implement this function to execute platform specific code when a payment's metadata is updated. - * For example: the user modifies the payment description. - * - * This function is invoked inside the same transaction used to add/modify the row. - * This means any database operations performed in this function are atomic, - * with respect to the referenced row. - */ -expect fun didUpdateWalletPaymentMetadata(id: UUID, database: PaymentsDatabase) - -/** - * Implement this function to execute platform specific code when a contact is saved to the database. - * For example, on iOS this is used to enqueue the (encrypted) contact for upload to CloudKit. - * - * This function is invoked inside the same transaction used to add/modify the row. - * This means any database operations performed in this function are atomic, - * with respect to the referenced row. - */ -expect fun didSaveContact(contactId: UUID, database: PaymentsDatabase) - -/** - * Implement this function to execute platform specific code when a contact is deleted. - * For example, on iOS this is used to enqueue an operation to delete the contact from CloudKit. - */ -expect fun didDeleteContact(contactId: UUID, database: PaymentsDatabase) - -/** - * Implemented on Apple platforms with support for CloudKit. - */ -expect fun makeCloudKitDb(appDb: SqliteAppDb, paymentsDb: SqlitePaymentsDb): CloudKitInterface? diff --git a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbInitHelper.kt b/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbInitHelper.kt deleted file mode 100644 index 3924b514..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/DbInitHelper.kt +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file:OptIn(ExperimentalStdlibApi::class) - -package fr.acinq.phoenix.db - - -import app.cash.sqldelight.ColumnAdapter -import app.cash.sqldelight.EnumColumnAdapter -import app.cash.sqldelight.db.SqlDriver -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.TxId -import fr.acinq.lightning.channel.states.PersistedChannelState -import fr.acinq.lightning.db.IncomingPayment -import fr.acinq.lightning.db.OutgoingPayment -import fr.acinq.lightning.db.WalletPayment -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.lightning.logging.error -import fr.acinq.lightning.utils.UUID -import fr.acinq.phoenix.data.ContactInfo -import fr.acinq.phoenix.db.sqldelight.ChannelsDatabase -import fr.acinq.phoenix.db.sqldelight.Cloudkit_payments_metadata -import fr.acinq.phoenix.db.sqldelight.Cloudkit_payments_queue -import fr.acinq.phoenix.db.sqldelight.Contacts -import fr.acinq.phoenix.db.sqldelight.Htlc_infos -import fr.acinq.phoenix.db.sqldelight.Link_lightning_outgoing_payment_parts -import fr.acinq.phoenix.db.sqldelight.Local_channels -import fr.acinq.phoenix.db.sqldelight.On_chain_txs -import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase -import fr.acinq.phoenix.db.sqldelight.Payments_incoming -import fr.acinq.phoenix.db.sqldelight.Payments_metadata -import fr.acinq.phoenix.db.sqldelight.Payments_outgoing -import fr.acinq.phoenix.managers.PaymentMetadataQueue -import fr.acinq.phoenix.utils.extensions.toByteArray - -fun createSqliteChannelsDb(driver: SqlDriver): SqliteChannelsDb { - return SqliteChannelsDb( - driver = driver, - database = ChannelsDatabase( - driver = driver, - htlc_infosAdapter = Htlc_infos.Adapter(ByteVector32Adapter, ByteVector32Adapter), - local_channelsAdapter = Local_channels.Adapter(ByteVector32Adapter, PersistedChannelStateAdapter) - ) - ) -} - -fun createSqlitePaymentsDb(driver: SqlDriver, metadataQueue: PaymentMetadataQueue?, loggerFactory: LoggerFactory): SqlitePaymentsDb { - val onError: (String) -> Unit = { loggerFactory.newLogger(SqlitePaymentsDb::class).error { it } } - return SqlitePaymentsDb( - driver = driver, - database = PaymentsDatabase( - driver = driver, - payments_incomingAdapter = Payments_incoming.Adapter(UUIDAdapter, ByteVector32Adapter, TxIdAdapter, IncomingPaymentAdapter(onError)), - payments_outgoingAdapter = Payments_outgoing.Adapter(UUIDAdapter, ByteVector32Adapter, TxIdAdapter, OutgoingPaymentAdapter(onError)), - link_lightning_outgoing_payment_partsAdapter = Link_lightning_outgoing_payment_parts.Adapter(UUIDAdapter, UUIDAdapter), - on_chain_txsAdapter = On_chain_txs.Adapter(UUIDAdapter, TxIdAdapter), - payments_metadataAdapter = Payments_metadata.Adapter(UUIDAdapter, EnumColumnAdapter(), EnumColumnAdapter(), EnumColumnAdapter()), - cloudkit_payments_queueAdapter = Cloudkit_payments_queue.Adapter(UUIDAdapter), - cloudkit_payments_metadataAdapter = Cloudkit_payments_metadata.Adapter(UUIDAdapter), - contactsAdapter = Contacts.Adapter(UUIDAdapter, ContactInfoAdapter) - ), - paymentMetadataQueue = metadataQueue, - loggerFactory = loggerFactory - ) -} - -object UUIDAdapter : ColumnAdapter { - 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 deleted file mode 100644 index 8b97c44d..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/SqliteAppDb.kt +++ /dev/null @@ -1,175 +0,0 @@ -package fr.acinq.phoenix.db - -import app.cash.sqldelight.EnumColumnAdapter -import app.cash.sqldelight.coroutines.asFlow -import app.cash.sqldelight.db.SqlDriver -import fr.acinq.lightning.utils.UUID -import fr.acinq.lightning.utils.currentTimestampMillis -import fr.acinq.phoenix.data.ExchangeRate -import fr.acinq.phoenix.data.FiatCurrency -import fr.acinq.phoenix.data.Notification -import fr.acinq.phoenix.db.notifications.NotificationsQueries -import fr.acinq.phoenix.db.sqldelight.AppDatabase -import fr.acinq.phoenix.db.sqldelight.Exchange_rates -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.withContext - -class SqliteAppDb(val driver: SqlDriver) { - - internal val database = AppDatabase( - driver = driver, - exchange_ratesAdapter = Exchange_rates.Adapter( - typeAdapter = EnumColumnAdapter() - ), - ) - - private val priceQueries = database.exchangeRatesQueries - private val keyValueStoreQueries = database.keyValueStoreQueries - private val notificationsQueries = NotificationsQueries(database) - - /** - * Save a list of [ExchangeRate] items to the database. - * Inserts new items, and updates existing items. - */ - suspend fun saveExchangeRates(rates: List) { - 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 deleted file mode 100644 index c94374df..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/SqliteChannelsDb.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2020 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.db - -import app.cash.sqldelight.db.SqlDriver -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.lightning.CltvExpiry -import fr.acinq.lightning.channel.states.PersistedChannelState -import fr.acinq.lightning.db.ChannelsDb -import fr.acinq.phoenix.db.sqldelight.ChannelsDatabase -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -class SqliteChannelsDb(val driver: SqlDriver, database: ChannelsDatabase) : ChannelsDb { - - private val queries = database.channelsDatabaseQueries - - override suspend fun addOrUpdateChannel(state: PersistedChannelState) { - withContext(Dispatchers.Default) { - queries.transaction { - queries.getChannel(state.channelId).executeAsOneOrNull()?.run { - queries.updateChannel(channel_id = state.channelId, data_ = state) - } ?: run { - queries.insertChannel(channel_id = state.channelId, data_ = state) - } - } - } - } - - suspend fun getChannel(channelId: ByteVector32): Triple? { - 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 deleted file mode 100644 index ff6c1b94..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/SqlitePaymentsDb.kt +++ /dev/null @@ -1,342 +0,0 @@ -/* - * Copyright 2020 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.db - -import app.cash.sqldelight.coroutines.asFlow -import app.cash.sqldelight.coroutines.mapToList -import app.cash.sqldelight.db.SqlDriver -import fr.acinq.bitcoin.TxId -import fr.acinq.lightning.db.AutomaticLiquidityPurchasePayment -import fr.acinq.lightning.db.Bolt11IncomingPayment -import fr.acinq.lightning.db.Bolt12IncomingPayment -import fr.acinq.lightning.db.IncomingPaymentsDb -import fr.acinq.lightning.db.LegacyPayToOpenIncomingPayment -import fr.acinq.lightning.db.LegacySwapInIncomingPayment -import fr.acinq.lightning.db.LightningIncomingPayment -import fr.acinq.lightning.db.LightningOutgoingPayment -import fr.acinq.lightning.db.ManualLiquidityPurchasePayment -import fr.acinq.lightning.db.NewChannelIncomingPayment -import fr.acinq.lightning.db.OnChainIncomingPayment -import fr.acinq.lightning.db.OnChainOutgoingPayment -import fr.acinq.lightning.db.OutgoingPaymentsDb -import fr.acinq.lightning.db.PaymentsDb -import fr.acinq.lightning.db.WalletPayment -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.lightning.logging.error -import fr.acinq.lightning.utils.UUID -import fr.acinq.lightning.utils.currentTimestampMillis -import fr.acinq.lightning.wire.LiquidityAds -import fr.acinq.phoenix.data.ContactAddress -import fr.acinq.phoenix.data.WalletPaymentInfo -import fr.acinq.phoenix.data.WalletPaymentMetadata -import fr.acinq.phoenix.db.contacts.SqliteContactsDb -import fr.acinq.phoenix.db.payments.LnurlBase -import fr.acinq.phoenix.db.payments.LnurlMetadata -import fr.acinq.phoenix.db.payments.LnurlSuccessAction -import fr.acinq.phoenix.db.payments.PaymentsMetadataQueries -import fr.acinq.phoenix.db.payments.SqliteIncomingPaymentsDb -import fr.acinq.phoenix.db.payments.SqliteOutgoingPaymentsDb -import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase -import fr.acinq.phoenix.managers.PaymentMetadataQueue -import fr.acinq.phoenix.utils.extensions.incomingOfferMetadata -import fr.acinq.phoenix.utils.extensions.outgoingInvoiceRequest -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.withContext - -class SqlitePaymentsDb( - val driver: SqlDriver, - val database: PaymentsDatabase, - val paymentMetadataQueue: PaymentMetadataQueue?, - val loggerFactory: LoggerFactory -) : IncomingPaymentsDb by SqliteIncomingPaymentsDb(database, paymentMetadataQueue), - OutgoingPaymentsDb by SqliteOutgoingPaymentsDb(database, paymentMetadataQueue), - PaymentsDb { - - val metadataQueries = PaymentsMetadataQueries(database) - val contacts = SqliteContactsDb(driver, database, loggerFactory) - - val log = loggerFactory.newLogger(SqlitePaymentsDb::class) - - override suspend fun getInboundLiquidityPurchase(txId: TxId): LiquidityAds.LiquidityTransactionDetails? { - val payment = buildList { - addAll(database.paymentsIncomingQueries.listByTxId(txId).executeAsList()) - addAll(database.paymentsOutgoingQueries.listByTxId(txId).executeAsList()) - }.firstOrNull() - @Suppress("DEPRECATION") - return when (payment) { - is LightningIncomingPayment -> payment.liquidityPurchaseDetails - is OnChainIncomingPayment -> payment.liquidityPurchaseDetails - is LegacyPayToOpenIncomingPayment -> null - is LegacySwapInIncomingPayment -> null - is LightningOutgoingPayment -> null - is OnChainOutgoingPayment -> payment.liquidityPurchaseDetails - null -> null - } - } - - override suspend fun setLocked(txId: TxId) { - database.transaction { - val lockedAt = currentTimestampMillis() - database.onChainTransactionsQueries.setLocked(tx_id = txId, locked_at = lockedAt) - database.paymentsIncomingQueries.listByTxId(txId).executeAsList().filterIsInstance().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 deleted file mode 100644 index 5acf8d53..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/CloudHelper.kt +++ /dev/null @@ -1,18 +0,0 @@ -package fr.acinq.phoenix.db.cloud - -import io.ktor.util.decodeBase64Bytes -import io.ktor.util.encodeBase64 - -// Kotlin wants to encode a ByteArray like this: { -// "fail": [123,34,112,97,121,109,101,110,116,82,101,113,117] -// } -// -// Lol. If we don't use Cbor, then we should at least use Base64. - -fun ByteArray.b64Encode(): String { - return this.encodeBase64() // io.ktor.util -} - -fun String.b64Decode(): ByteArray { - return this.decodeBase64Bytes() // io.ktor.util -} 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 deleted file mode 100644 index 1b7948da..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/CloudSerializers.kt +++ /dev/null @@ -1,148 +0,0 @@ -package fr.acinq.phoenix.db.cloud - -import fr.acinq.bitcoin.ByteVector -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.utils.Try -import fr.acinq.lightning.utils.UUID -import fr.acinq.lightning.wire.OfferTypes -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerializationException -import kotlinx.serialization.cbor.Cbor -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - -// Notes from the field: -// -// Consider the following JSON: -// { -// "preimage":"JuO9VOOW/5pzCKsaCO7a9E/ETS7Bef5yyWVRBJBYmOQ=", -// "origin":{ -// "type":"INVOICE_V0", -// "blob":"eyJwYXltZW50UmVxdWVzdCI6ImxudGIxMDB1MXBzMDNmc3VwcDVnbmh4NmR4NTA4cnM3OG1kcnB3Y3U3cWgwNGZja3hjbmRhdXdueXp1NjRuM3V5dzRqZHRzZHE1ZmFjeDJtM3F2ZDV4em1ud3Y0a3FjcXBqc3A1aHIyZDg1cDdkNm5xcGtyZXVtNGo5czZycjBwaG1weGhzdzVqYXJyNTI5ZGxsYXY5MGZ3cTlxdHpxcXFxcXF5c2dxeHF5anc1cXJ6anF3Zm4zcDkyNzh0dHp6cGUwZTAwdWh5eGhuZWQzajVkOWFjcWFrNWVtd2ZwZmxwOHoyY25mbGNmemNhODA1NmsweXFxcXFsZ3FxcXFxZXFxanFmOTN4M3YyM3IwZTg1a3p5cXJlaDY4ZGhxbGQwY2w3and4OTdwZDZwemF4Y2N1Y3l3NzhxZGc1ZzJsdzhrZnlmdWQzMnN4d2NtNDlhY2wwNXdxd3phajA4djdyeHQ5cWZ4Z2E3MDNzcGhrcmZhdCJ9" -// }, -// "received":{ -// "ts":1626908236089, -// "type":"MULTIPARTS_V0", -// "blob":"W3sidHlwZSI6ImZyLmFjaW5xLnBob2VuaXguZGIucGF5bWVudHMuSW5jb21pbmdSZWNlaXZlZFdpdGhEYXRhLlBhcnQuTmV3Q2hhbm5lbC5WMCIsImFtb3VudCI6eyJtc2F0IjoxMDAwMDAwMH0sImZlZXMiOnsibXNhdCI6MzAwMDAwMH0sImNoYW5uZWxJZCI6bnVsbH1d" -// }, -// "createdAt":1626908189069 -// } -// -// Now there are 4 different ways in which we can encode this data. -// -// 1. Use JSON serialization, and encode the data as Base64. -// The output looks exactly like the above. -// ``` -// @Serializable(with = ByteVector32JsonSerializer::class) -// val preimage: ByteVector32 -// ``` -// -// 2. Use CBOR serialization, and encode the data as Base64. -// ``` -// @Serializable(with = ByteVector32JsonSerializer::class) -// val preimage: ByteVector32 -// ``` -// -// 3. Use CBOR serialization, and encode the data as ByteArray. -// Since CBOR supports raw data, this should encode smaller. -// ``` -// val preimage: ByteArray -// ``` -// -// 4. Use CBOR serialization, and encode the data as ByteArray w/ByteString. -// The docs mention that we can opt-in to use CBOR major type 2. -// ``` -// @ByteString -// val preimage: ByteArray -// ``` -// -// After attempting all the above options, here are the results: -// 1. 915 bytes -// 2. 883 bytes -// 3. 1,256 bytes (huh?) -// 4. 690 bytes ! -// -// The winner is CBOR with @ByteString. - -/** - * Standard Cbor instance for serialization. - */ -@OptIn(ExperimentalSerializationApi::class) -fun cborSerializer() = Cbor { ignoreUnknownKeys = true } - -/** - * Serializer that uses base64 encoding. - * (this is more compact than hexadecimal encoding) - */ -object ByteVectorJsonSerializer : KSerializer { - 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 deleted file mode 100644 index edc54ae2..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/contacts/CloudContact.kt +++ /dev/null @@ -1,109 +0,0 @@ -package fr.acinq.phoenix.db.cloud.contacts - -import fr.acinq.lightning.utils.UUID -import fr.acinq.lightning.utils.currentTimestampMillis -import fr.acinq.lightning.wire.OfferTypes -import fr.acinq.phoenix.data.ContactInfo -import fr.acinq.phoenix.data.ContactOffer -import fr.acinq.phoenix.db.cloud.OfferSerializer -import fr.acinq.phoenix.db.cloud.UUIDSerializer -import fr.acinq.phoenix.db.cloud.cborSerializer -import fr.acinq.phoenix.db.serialization.contacts.Serialization -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromByteArray - -sealed class CloudContact { - - enum class Version(val value: Int) { - // Initial version - V0(0) - } - - @Serializable - data class VersionSwitch( - @SerialName("v") - val version: Int - ) - - @Serializable - data class V0( - @SerialName("v") - val version: Int, - @Serializable(with = UUIDSerializer::class) - val id: UUID, - val name: String, - val useOfferKey: Boolean, - val offers: List<@Serializable(OfferSerializer::class) OfferTypes.Offer>, - ): CloudContact() { - - @Throws(Exception::class) - fun unwrap(photoUri: String?): ContactInfo { - val now = currentTimestampMillis() - val mappedOffers: List = 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 deleted file mode 100644 index 6816b406..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/ChannelCloseType.kt +++ /dev/null @@ -1,47 +0,0 @@ -package fr.acinq.phoenix.db.cloud.payments - -import fr.acinq.bitcoin.TxId -import fr.acinq.lightning.db.ChannelCloseOutgoingPayment -import fr.acinq.lightning.db.ChannelCloseOutgoingPayment.ChannelClosingType -import fr.acinq.lightning.utils.UUID -import fr.acinq.lightning.utils.sat -import fr.acinq.lightning.utils.toByteVector32 -import fr.acinq.phoenix.db.cloud.UUIDSerializer -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.cbor.ByteString - -@Serializable -@OptIn(ExperimentalSerializationApi::class) -data class ChannelClosePaymentWrapper( - @Serializable(with = UUIDSerializer::class) - val id: UUID, - val amountSat: Long, - val address: String, - val isSentToDefaultAddress: Boolean, - val miningFeeSat: Long, - @ByteString val txId: ByteArray, - val createdAt: Long, - val confirmedAt: Long?, - val lockedAt: Long?, - @ByteString val channelId: ByteArray, - val closingType: ChannelClosingType, -) { - - @Throws(Exception::class) - fun unwrap() = ChannelCloseOutgoingPayment( - id = id, - recipientAmount = amountSat.sat, - address = address, - isSentToDefaultAddress = isSentToDefaultAddress, - miningFee = miningFeeSat.sat, - txId = TxId(txId), - createdAt = createdAt, - confirmedAt = confirmedAt, - lockedAt = lockedAt, - channelId = channelId.toByteVector32(), - closingType = closingType - ) - - companion object -} 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 deleted file mode 100644 index 2b725bdc..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/CloudAsset.kt +++ /dev/null @@ -1,139 +0,0 @@ -package fr.acinq.phoenix.db.cloud.payments - -import fr.acinq.lightning.utils.currentTimestampMillis -import fr.acinq.phoenix.db.cloud.cborSerializer -import fr.acinq.phoenix.db.payments.LnurlBase -import fr.acinq.phoenix.db.payments.LnurlMetadata -import fr.acinq.phoenix.db.payments.LnurlSuccessAction -import fr.acinq.phoenix.db.payments.WalletPaymentMetadataRow -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.cbor.ByteString -import kotlinx.serialization.cbor.Cbor -import kotlinx.serialization.decodeFromByteArray -import kotlinx.serialization.encodeToByteArray - -enum class CloudAssetVersion(val value: Int) { - // Initial version - V0(0), - // V1: - // - added `original_fiat` - V1(1) - // Future versions go here -} - -// Upgrade notes: -// If needed in the future, you can use code like this to extract only the version: -// -// data class CloudAssetVersion( -// @SerialName("v") -// val version: Int -// ) -// val version = try { -// cborSerializer().decodeFromByteArray(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 deleted file mode 100644 index 02c82bf0..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/CloudData.kt +++ /dev/null @@ -1,157 +0,0 @@ -package fr.acinq.phoenix.db.cloud.payments - -import fr.acinq.lightning.db.WalletPayment -import fr.acinq.lightning.serialization.payment.Serialization -import fr.acinq.phoenix.db.cloud.cborSerializer -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.cbor.ByteString -import kotlinx.serialization.decodeFromByteArray -import kotlinx.serialization.json.Json - -// Architecture & Notes: -// -// We make every attempt to re-use code from the database serialization routines. -// However, cloud serialization is a bit different from database serialization. -// -// DIFFERENCE #1: -// -// SqlDelight allows us to version the database, and make changes to the database structure. -// For example, when upgrading the app from v1.5 to v1.6, -// we might upgrade the database from v2 to v3. -// This upgrade mechanism allows us to write code that **only** supports database v3, -// since SqlDelight handles the upgrade mechanics. -// -// This upgrade mechanism isn't available for the cloud. -// Thus, in the cloud we might have serialized objects in v1, v2, v3... -// And we will need to support deserializing all these versions. -// -// DIFFERENCE #2: -// -// For the local system, space is cheap, and the disk is fast. -// But for the cloud, space is really expensive. -// On iOS, the user only has 5 GB. But that's NOT per app. -// That 5GB is meant to be shared by every app on their phone. -// Which means we're expected to be a good steward of their cloud space. -// -// So we make certain changes. -// -// OPTIMIZATION #1: -// -// We use CBOR instead of JSON. -// This allows us to very efficiently encode ByteArray's. -// -// For a discussion of the space savings in practice, -// see the comments in CloudSerializers.kt. -// -// OPTIMIZATION #2: -// -// We use custom serializers when the other versions are found to -// be inefficient (in terms of space). -// For example, we supply an alternative UUIDSerializer. - - -enum class CloudDataVersion(val value: Int) { - // Initial version - V0(0) - // Future versions go here -} - -sealed class CloudData { - @OptIn(ExperimentalSerializationApi::class) - @Serializable - data class V0( - @SerialName("i") - val incoming: IncomingPaymentWrapperV10Legacy? = null, - @ByteString - @SerialName("o") - val outgoing: LightningOutgoingPaymentWrapper? = null, - @SerialName("so") - val spliceOutgoing: SpliceOutgoingPaymentWrapper? = null, - @SerialName("cc") - val channelClose: ChannelClosePaymentWrapper? = null, - @SerialName("sc") - val spliceCpfp: SpliceCpfpPaymentWrapper? = null, - @SerialName("il") - val inboundLegacyLiquidity: InboundLiquidityLegacyWrapper? = null, - @SerialName("ip") - val inboundPurchaseLiquidity: InboundLiquidityPaymentWrapper? = null, - @SerialName("v") - val version: Int, - @ByteString - @SerialName("p") - val padding: ByteArray?, - ) : CloudData() { - - /** - * This function exists because the Kotlin-generated - * copy function doesn't translate to iOS very well. - */ - fun copyWithPadding(padding: ByteArray): V0 { - return this.copy(padding = padding) - } - - @Throws(Exception::class) - fun unwrap(): WalletPayment? = when { - incoming != null -> incoming.unwrap() - outgoing != null -> outgoing.unwrap() - spliceOutgoing != null -> spliceOutgoing.unwrap() - channelClose != null -> channelClose.unwrap() - spliceCpfp != null -> spliceCpfp.unwrap() - inboundLegacyLiquidity != null -> inboundLegacyLiquidity.unwrap() - inboundPurchaseLiquidity != null -> inboundPurchaseLiquidity.unwrap() - else -> null - } - - override fun serialize(): ByteArray = throw NotImplementedError("cannot create V0 cloud data anymore") - - companion object { - @OptIn(ExperimentalSerializationApi::class) - private fun cborDeserialize(blob: ByteArray): CloudData { - return cborSerializer().decodeFromByteArray(blob) - } - } - } - - data class V1(val payment: WalletPayment) : CloudData() { - override fun serialize(): ByteArray { - val version = byteArrayOf(1.toByte()) - val serializedDate = Serialization.serialize(payment) - return version + serializedDate - } - } - - abstract fun serialize(): ByteArray - companion object { - @OptIn(ExperimentalSerializationApi::class) - fun deserialize(data: ByteArray): CloudData? { - return kotlin.runCatching { - when (val version = data.first()) { - 1.toByte() -> { - val serializedData = data.sliceArray(1.. { - 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 deleted file mode 100644 index bc33ba60..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/InboundLiquidityPaymentWrapper.kt +++ /dev/null @@ -1,146 +0,0 @@ -package fr.acinq.phoenix.db.cloud.payments - -import fr.acinq.bitcoin.TxId -import fr.acinq.lightning.db.AutomaticLiquidityPurchasePayment -import fr.acinq.lightning.db.ManualLiquidityPurchasePayment -import fr.acinq.lightning.db.WalletPayment -import fr.acinq.lightning.utils.UUID -import fr.acinq.lightning.utils.sat -import fr.acinq.lightning.utils.toByteVector32 -import fr.acinq.lightning.wire.LiquidityAds -import fr.acinq.phoenix.db.cloud.UUIDSerializer -import fr.acinq.phoenix.db.migrations.v11.types.liquidityads.PurchaseData -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.cbor.ByteString - - -/** New inbound liquidity wrapper that uses the [LiquidityAds.Purchase] object. */ -@Suppress("ArrayInDataClass") -@OptIn(ExperimentalSerializationApi::class) -@Serializable -data class InboundLiquidityPaymentWrapper( - @Serializable(with = UUIDSerializer::class) - val id: UUID, - @ByteString val channelId: ByteArray, - @ByteString val txId: ByteArray, - val miningFeesSat: Long, - val purchase: LiquidityAdsPurchaseWrapper, - val createdAt: Long, - val confirmedAt: Long?, - val lockedAt: Long?, -) { - @Throws(Exception::class) - fun unwrap(): WalletPayment { - val purchase = this.purchase.unwrap() - return when (purchase.paymentDetails) { - is LiquidityAds.PaymentDetails.FromFutureHtlc, is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage, is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> { - AutomaticLiquidityPurchasePayment( - id = this.id, - channelId = this.channelId.toByteVector32(), - txId = TxId(this.txId), - miningFee = this.miningFeesSat.sat, - liquidityPurchase = purchase, - createdAt = this.createdAt, - confirmedAt = this.confirmedAt, - lockedAt = this.lockedAt, - incomingPaymentReceivedAt = this.confirmedAt - ) - } - is LiquidityAds.PaymentDetails.FromChannelBalance -> { - ManualLiquidityPurchasePayment( - id = this.id, - channelId = this.channelId.toByteVector32(), - txId = TxId(this.txId), - miningFee = this.miningFeesSat.sat, - liquidityPurchase = purchase, - createdAt = this.createdAt, - confirmedAt = this.confirmedAt, - lockedAt = this.lockedAt, - ) - } - } - } - - @Serializable - data class LiquidityAdsPurchaseWrapper(@ByteString val blob: ByteArray) { - fun unwrap(): LiquidityAds.Purchase { - return PurchaseData.decodeAsCanonical("", blob) - } - } -} - -/** This is the legacy wrapper for inbound liquidity, that used a Lease object to represent the liquidity purchase. Used only for deserialization now. */ -@Serializable -@Suppress("ArrayInDataClass") -@OptIn(ExperimentalSerializationApi::class) -data class InboundLiquidityLegacyWrapper( - @Serializable(with = UUIDSerializer::class) - val id: UUID, - @ByteString val channelId: ByteArray, - @ByteString val txId: ByteArray, - val miningFeesSat: Long, - val lease: LiquidityAdsLeaseWrapper, - val createdAt: Long, - val confirmedAt: Long?, - val lockedAt: Long?, -) { - @Throws(Exception::class) - fun unwrap(): WalletPayment { - val purchase = this.lease.unwrap() - return when (purchase.paymentDetails) { - is LiquidityAds.PaymentDetails.FromFutureHtlc, is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage, is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> { - AutomaticLiquidityPurchasePayment( - id = this.id, - channelId = this.channelId.toByteVector32(), - txId = TxId(this.txId), - miningFee = this.miningFeesSat.sat, - liquidityPurchase = purchase, - createdAt = this.createdAt, - confirmedAt = this.confirmedAt, - lockedAt = this.lockedAt, - incomingPaymentReceivedAt = this.confirmedAt - ) - } - is LiquidityAds.PaymentDetails.FromChannelBalance -> { - ManualLiquidityPurchasePayment( - id = this.id, - channelId = this.channelId.toByteVector32(), - txId = TxId(this.txId), - miningFee = this.miningFeesSat.sat, - liquidityPurchase = purchase, - createdAt = this.createdAt, - confirmedAt = this.confirmedAt, - lockedAt = this.lockedAt, - ) - } - } - } - - @Serializable - data class LiquidityAdsLeaseWrapper( - val amountSat: Long, - val fees: LiquidityAdsLeaseFeesWrapper, - ) { - @Throws(Exception::class) - fun unwrap(): LiquidityAds.Purchase{ - return LiquidityAds.Purchase.Standard( - amount = this.amountSat.sat, - fees = this.fees.unwrap().let { LiquidityAds.Fees(miningFee = it.miningFee, serviceFee = it.serviceFee) }, - paymentDetails = LiquidityAds.PaymentDetails.FromChannelBalance - ) - } - } - - @Serializable - data class LiquidityAdsLeaseFeesWrapper( - val miningFeeSat: Long, - val serviceFeeSat: Long - ) { - @Throws(Exception::class) - fun unwrap() = LiquidityAds.Fees( - miningFee = this.miningFeeSat.sat, - serviceFee = this.serviceFeeSat.sat - ) - } -} \ 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 deleted file mode 100644 index d31d101d..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/IncomingPaymentWrapperV10Legacy.kt +++ /dev/null @@ -1,46 +0,0 @@ -package fr.acinq.phoenix.db.cloud.payments - -import fr.acinq.bitcoin.byteVector32 -import fr.acinq.lightning.db.IncomingPayment -import fr.acinq.phoenix.db.migrations.v10.types.mapIncomingPaymentFromV10 -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.cbor.ByteString - -@Serializable -@OptIn(ExperimentalSerializationApi::class) -data class IncomingPaymentWrapperV10Legacy( - @ByteString val preimage: ByteArray, - val origin: OriginWrapper, - val received: ReceivedWrapper?, - val createdAt: Long -) { - fun unwrap(): IncomingPayment { - return mapIncomingPaymentFromV10( - preimage = preimage, - payment_hash = preimage.byteVector32().sha256().toByteArray(), - created_at = createdAt, - origin_type = origin.type, - origin_blob = origin.blob, - received_amount_msat = null, - received_at = received?.ts, - received_with_type = received?.type, - received_with_blob = received?.blob, - ) - } - - @Serializable - @OptIn(ExperimentalSerializationApi::class) - data class OriginWrapper( - val type: String, - @ByteString val blob: ByteArray - ) - - @Serializable - @OptIn(ExperimentalSerializationApi::class) - data class ReceivedWrapper( - val ts: Long, // timestamp / receivedAt - val type: String, - @ByteString val blob: ByteArray - ) -} 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 deleted file mode 100644 index 308b372f..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/LightningOutgoingPartType.kt +++ /dev/null @@ -1,68 +0,0 @@ -package fr.acinq.phoenix.db.cloud.payments - -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.db.LightningOutgoingPayment -import fr.acinq.lightning.utils.UUID -import fr.acinq.phoenix.db.cloud.UUIDSerializer -import fr.acinq.phoenix.db.migrations.v11.queries.LightningOutgoingQueries -import fr.acinq.phoenix.db.migrations.v11.types.OutgoingPartStatusData -import fr.acinq.phoenix.db.migrations.v11.types.OutgoingPartStatusTypeVersion -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.cbor.ByteString - - -/** Legacy object used when channel closing were stored as outgoing-payments parts. */ -@Serializable -@OptIn(ExperimentalSerializationApi::class) -data class LightningOutgoingClosingTxPartWrapper( - @Serializable(with = UUIDSerializer::class) val id: UUID, - @ByteString val txId: ByteArray, - val sat: Long, - val info: ClosingInfoWrapper, - val createdAt: Long -) { - @Serializable - @OptIn(ExperimentalSerializationApi::class) - data class ClosingInfoWrapper( - val type: String, - @ByteString val blob: ByteArray - ) -} - -@Serializable -data class LightningOutgoingPartWrapper( - @Serializable(with = UUIDSerializer::class) - val id: UUID, - val msat: Long, - val route: String, - val status: StatusWrapper?, - val createdAt: Long -) { - - fun unwrap() = LightningOutgoingPayment.Part( - id = id, - amount = MilliSatoshi(msat = msat), - route = LightningOutgoingQueries.hopDescAdapter.decode(route), - status = status?.unwrap() ?: LightningOutgoingPayment.Part.Status.Pending, - createdAt = createdAt - ) - - @Serializable - @OptIn(ExperimentalSerializationApi::class) - data class StatusWrapper( - val ts: Long, // timestamp: completedAt - val type: String, - @ByteString - val blob: ByteArray - ) { - fun unwrap(): LightningOutgoingPayment.Part.Status { - return OutgoingPartStatusData.deserialize( - typeVersion = OutgoingPartStatusTypeVersion.valueOf(type), - blob = blob, - completedAt = ts - ) - } - } // - -} // 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 deleted file mode 100644 index 3a24b3d7..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/LightningOutgoingType.kt +++ /dev/null @@ -1,107 +0,0 @@ -package fr.acinq.phoenix.db.cloud.payments - -import fr.acinq.bitcoin.PublicKey -import fr.acinq.bitcoin.byteVector32 -import fr.acinq.lightning.db.ChannelCloseOutgoingPayment -import fr.acinq.lightning.db.LightningOutgoingPayment -import fr.acinq.lightning.db.OutgoingPayment -import fr.acinq.lightning.utils.UUID -import fr.acinq.lightning.utils.msat -import fr.acinq.lightning.utils.sat -import fr.acinq.phoenix.db.cloud.UUIDSerializer -import fr.acinq.phoenix.db.migrations.v11.types.OutgoingDetailsData -import fr.acinq.phoenix.db.migrations.v11.types.OutgoingDetailsTypeVersion -import fr.acinq.phoenix.db.migrations.v11.types.OutgoingStatusData -import fr.acinq.phoenix.db.migrations.v11.types.OutgoingStatusTypeVersion -import fr.acinq.phoenix.utils.migrations.LegacyChannelCloseHelper -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.cbor.ByteString - -@Serializable -@OptIn(ExperimentalSerializationApi::class) -data class LightningOutgoingPaymentWrapper( - @Serializable(with = UUIDSerializer::class) - val id: UUID, - val msat: Long, - @ByteString - val recipient: ByteArray, - val details: DetailsWrapper, - val parts: List, - // 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 deleted file mode 100644 index 7dbf0978..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/SpliceCpfpPaymentType.kt +++ /dev/null @@ -1,37 +0,0 @@ -package fr.acinq.phoenix.db.cloud.payments - -import fr.acinq.bitcoin.TxId -import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment -import fr.acinq.lightning.utils.UUID -import fr.acinq.lightning.utils.sat -import fr.acinq.lightning.utils.toByteVector32 -import fr.acinq.phoenix.db.cloud.UUIDSerializer -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.cbor.ByteString - -@Serializable -@OptIn(ExperimentalSerializationApi::class) -data class SpliceCpfpPaymentWrapper( - @Serializable(with = UUIDSerializer::class) - val id: UUID, - val miningFeeSat: Long, - @ByteString - val channelId: ByteArray, - @ByteString - val txId: ByteArray, - val createdAt: Long, - val confirmedAt: Long?, - val lockedAt: Long? -) { - @Throws(Exception::class) - fun unwrap() = SpliceCpfpOutgoingPayment( - id = id, - miningFee = miningFeeSat.sat, - channelId = channelId.toByteVector32(), - txId = TxId(txId), - createdAt = createdAt, - confirmedAt = confirmedAt, - lockedAt = lockedAt, - ) -} 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 deleted file mode 100644 index 5226ea17..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/cloud/payments/SpliceOutgoingType.kt +++ /dev/null @@ -1,40 +0,0 @@ -package fr.acinq.phoenix.db.cloud.payments - -import fr.acinq.bitcoin.TxId -import fr.acinq.lightning.db.SpliceOutgoingPayment -import fr.acinq.lightning.utils.UUID -import fr.acinq.lightning.utils.sat -import fr.acinq.lightning.utils.toByteVector32 -import fr.acinq.phoenix.db.cloud.UUIDSerializer -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.cbor.ByteString - -@Serializable -@OptIn(ExperimentalSerializationApi::class) -data class SpliceOutgoingPaymentWrapper( - @Serializable(with = UUIDSerializer::class) - val id: UUID, - val amountSat: Long, - val address: String, - val miningFeeSat: Long, - @ByteString val txId: ByteArray, - @ByteString val channelId: ByteArray, - val createdAt: Long, - val confirmedAt: Long?, - val lockedAt: Long?, -) { - @Throws(Exception::class) - fun unwrap() = SpliceOutgoingPayment( - id = id, - recipientAmount = amountSat.sat, - address = address, - miningFee = miningFeeSat.sat, - channelId = channelId.toByteVector32(), - txId = TxId(txId), - liquidityPurchase = null, - createdAt = createdAt, - confirmedAt = confirmedAt, - lockedAt = lockedAt, - ) -} 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 deleted file mode 100644 index f100e83a..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/contacts/ContactQueries.kt +++ /dev/null @@ -1,75 +0,0 @@ -package fr.acinq.phoenix.db.contacts - -import app.cash.sqldelight.coroutines.asFlow -import app.cash.sqldelight.coroutines.mapToList -import fr.acinq.lightning.utils.UUID -import fr.acinq.lightning.utils.currentTimestampMillis -import fr.acinq.phoenix.data.ContactInfo -import fr.acinq.phoenix.db.didDeleteContact -import fr.acinq.phoenix.db.didSaveContact -import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase -import kotlinx.coroutines.flow.Flow -import kotlin.coroutines.CoroutineContext - -class ContactQueries(val database: PaymentsDatabase) { - - val queries = database.contactsQueries - - fun saveContact(contact: ContactInfo, notify: Boolean = true) { - database.transaction { - val contactExists = queries.existsContact( - id = contact.id - ).executeAsOne() > 0 - if (contactExists) { - updateExistingContact(contact) - } else { - saveNewContact(contact) - } - if (notify) { - didSaveContact(contact.id, database) - } - } - } - - private fun saveNewContact(contact: ContactInfo) { - queries.insertContact( - id = contact.id, - data = contact, - createdAt = currentTimestampMillis(), - updatedAt = null - ) - } - - private fun updateExistingContact(contact: ContactInfo) { - queries.updateContact( - data = contact, - updatedAt = currentTimestampMillis(), - contactId = contact.id - ) - } - - fun existsContact(contactId: UUID): Boolean { - return queries.existsContact( - id = contactId - ).executeAsOne() > 0 - } - - fun getContact(contactId: UUID): ContactInfo? { - return queries.getContact(contactId).executeAsOneOrNull() - } - - fun listContacts(): List { - 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 deleted file mode 100644 index fb0cd886..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/contacts/SqliteContactsDb.kt +++ /dev/null @@ -1,171 +0,0 @@ -package fr.acinq.phoenix.db.contacts - -import app.cash.sqldelight.db.SqlDriver -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.PublicKey -import fr.acinq.lightning.db.Bolt12IncomingPayment -import fr.acinq.lightning.db.WalletPayment -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.lightning.utils.UUID -import fr.acinq.lightning.wire.OfferTypes -import fr.acinq.phoenix.data.ContactAddress -import fr.acinq.phoenix.data.ContactInfo -import fr.acinq.phoenix.data.WalletPaymentMetadata -import fr.acinq.phoenix.db.SqliteAppDb -import fr.acinq.phoenix.db.migrations.appDb.v7.AfterVersion7Result -import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase -import fr.acinq.phoenix.utils.extensions.incomingOfferMetadata -import fr.acinq.phoenix.utils.extensions.outgoingInvoiceRequest -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlin.time.Duration.Companion.seconds - - -class SqliteContactsDb( - val driver: SqlDriver, - val database: PaymentsDatabase, - val loggerFactory: LoggerFactory -): CoroutineScope by MainScope() { - - private val log = loggerFactory.newLogger(this::class) - - val contactQueries = ContactQueries(database) - - private val _contactsList = MutableStateFlow>(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 deleted file mode 100644 index ecf266df..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/appDb/v7/AfterVersion7.kt +++ /dev/null @@ -1,353 +0,0 @@ -package fr.acinq.phoenix.db.migrations.appDb.v7 - -import app.cash.sqldelight.TransacterImpl -import app.cash.sqldelight.db.QueryResult -import app.cash.sqldelight.db.SqlDriver -import fr.acinq.bitcoin.byteVector32 -import fr.acinq.bitcoin.utils.Try -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.lightning.logging.debug -import fr.acinq.lightning.logging.info -import fr.acinq.lightning.utils.UUID -import fr.acinq.lightning.utils.currentTimestampMillis -import fr.acinq.lightning.wire.OfferTypes -import fr.acinq.phoenix.data.ContactInfo -import fr.acinq.phoenix.data.ContactOffer -import fr.acinq.phoenix.db.serialization.contacts.Serialization -import fr.acinq.phoenix.utils.extensions.toByteArray - -enum class AfterVersion7Result { - MigrationWasAlreadyCompleted, - MigrationNowCompleted -} - -fun AfterVersion7( - appDbDriver: SqlDriver, - paymentsDbDriver: SqlDriver, - loggerFactory: LoggerFactory -): AfterVersion7Result { - - data class MetadataRow( - val id: String, - val recordCreation: Long, - val recordBlob: ByteArray - ) - - fun fetchMetadataBatch(): List { - - 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 deleted file mode 100644 index 31cdab32..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/AfterVersion10.kt +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.db.migrations.v10 - -import app.cash.sqldelight.db.AfterVersion -import app.cash.sqldelight.db.QueryResult -import fr.acinq.lightning.db.LegacyPayToOpenIncomingPayment -import fr.acinq.lightning.db.LegacySwapInIncomingPayment -import fr.acinq.lightning.db.LightningIncomingPayment -import fr.acinq.lightning.db.OnChainIncomingPayment -import fr.acinq.lightning.serialization.payment.Serialization -import fr.acinq.phoenix.db.migrations.v10.types.mapIncomingPaymentFromV10 -import fr.acinq.phoenix.utils.extensions.deriveUUID -import fr.acinq.phoenix.utils.extensions.toByteArray - -@OptIn(ExperimentalStdlibApi::class) -fun AfterVersion10(onError: (String) -> Unit) = AfterVersion(10) { driver -> - val payments = driver.executeQuery( - identifier = null, - sql = "SELECT * FROM incoming_payments", - parameters = 0, - mapper = { cursor -> - val result = buildList { - while (cursor.next().value) { - try { - val o = mapIncomingPaymentFromV10( - payment_hash = cursor.getBytes(0)!!, - preimage = cursor.getBytes(1)!!, - created_at = cursor.getLong(2)!!, - origin_type = cursor.getString(3)!!, - origin_blob = cursor.getBytes(4)!!, - received_amount_msat = cursor.getLong(5), - received_at = cursor.getLong(6), - received_with_type = cursor.getString(7), - received_with_blob = cursor.getBytes(8), - ) - add(o) - } catch (e: Exception) { - onError("(v10) cannot migrate legacy incoming data: ${e.message}" + - "\n payment_hash=${cursor.getBytes(0)?.toHexString()} preimage=${cursor.getBytes(1)?.toHexString()} received=${cursor.getLong(5)}" + - "\n origin_type=${cursor.getString(3)} origin_blob=${cursor.getBytes(4)?.toHexString()}" + - "\n rec_w_type=${cursor.getString(7)} rec_w_blob=${cursor.getBytes(8)?.toHexString()}") - } - } - } - QueryResult.Value(result) - } - ).value - - driver.execute(identifier = null, sql = "DROP TABLE incoming_payments", parameters = 0) - - payments - .forEach { payment -> - driver.execute( - identifier = null, - sql = "INSERT INTO payments_incoming (id, payment_hash, tx_id, created_at, received_at, data) VALUES (?, ?, ?, ?, ?, ?)", - parameters = 6 - ) { - when (payment) { - is LightningIncomingPayment -> { - bindBytes(0, payment.paymentHash.deriveUUID().toByteArray()) - bindBytes(1, payment.paymentHash.toByteArray()) - bindBytes(2, null) - } - is @Suppress("DEPRECATION") LegacyPayToOpenIncomingPayment -> { - bindBytes(0, payment.paymentHash.deriveUUID().toByteArray()) - bindBytes(1, payment.paymentHash.toByteArray()) - bindBytes(2, null) - } - is @Suppress("DEPRECATION") LegacySwapInIncomingPayment -> { - bindBytes(0, payment.id.toByteArray()) - bindBytes(1, null) - bindBytes(2, null) - } - is OnChainIncomingPayment -> { - bindBytes(0, payment.id.toByteArray()) - bindBytes(1, null) - bindBytes(2, payment.txId.value.toByteArray()) - } - } - bindLong(3, payment.createdAt) - bindLong(4, payment.completedAt) - bindBytes(5, Serialization.serialize(payment)) - } - } -} \ 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 deleted file mode 100644 index 68bea6d6..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/AbstractStringSerializer.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2022 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.db.migrations.v10.json - -import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - -abstract class AbstractStringSerializer( - 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 deleted file mode 100644 index 45cdd144..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/ByteVectorSerializer.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.db.migrations.v10.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 deleted file mode 100644 index 5dc4e117..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/MilliSatoshiSerializer.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.db.migrations.v10.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 deleted file mode 100644 index 4902b47e..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/OutpointSerializer.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.db.migrations.v10.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 deleted file mode 100644 index e7017943..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/SatoshiSerializer.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2022 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.db.migrations.v10.json - -import 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 deleted file mode 100644 index d63c511a..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/TxIdSerializer.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.db.migrations.v10.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 deleted file mode 100644 index aac8214d..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/json/UUIDSerializer.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.db.migrations.v10.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 deleted file mode 100644 index 3dfc32e3..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v10/types/IncomingTypes.kt +++ /dev/null @@ -1,503 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file: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.Bolt11IncomingPayment -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.NewChannelIncomingPayment -import fr.acinq.lightning.db.SpliceInIncomingPayment -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.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 -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 deleted file mode 100644 index 9c38c679..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/AfterVersion11.kt +++ /dev/null @@ -1,476 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.db.migrations.v11 - -import app.cash.sqldelight.EnumColumnAdapter -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 deleted file mode 100644 index e47153b3..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/ChannelCloseOutgoingQueries.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.db.migrations.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 deleted file mode 100644 index 217b6c41..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/InboundLiquidityQueries.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.db.migrations.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 deleted file mode 100644 index 288db456..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/LightningOutgoingQueries.kt +++ /dev/null @@ -1,224 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.db.migrations.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.UUID -import fr.acinq.lightning.utils.msat -import fr.acinq.lightning.utils.sat -import fr.acinq.lightning.utils.toByteVector32 -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 deleted file mode 100644 index 359d10d9..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/SpliceCpfpOutgoingQueries.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.db.migrations.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 deleted file mode 100644 index ba4b94d3..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/queries/SpliceOutgoingQueries.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.db.migrations.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 deleted file mode 100644 index ddd9a5c1..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingDetailsType.kt +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@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 fr.acinq.phoenix.db.migrations.v11.types.OutgoingDetailsData.Companion.deserializeLegacyClosingDetails -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 deleted file mode 100644 index feab4fd9..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingPartClosingType.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.db.migrations.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 deleted file mode 100644 index 441ee03d..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingPartStatusType.kt +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file: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 deleted file mode 100644 index 6b1871ec..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/OutgoingStatusType.kt +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file: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 deleted file mode 100644 index 3228f5ba..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/FundingFeeData.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file: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.lightning.wire.LiquidityAds -import fr.acinq.phoenix.db.migrations.v10.json.MilliSatoshiSerializer -import fr.acinq.phoenix.db.migrations.v10.json.TxIdSerializer -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 deleted file mode 100644 index 1796ef4c..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/LegacyLeaseData.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2023 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@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 deleted file mode 100644 index 2cce7003..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/PaymentDetailsData.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file:UseSerializers( - ByteVector32Serializer::class, -) - -package fr.acinq.phoenix.db.migrations.v11.types.liquidityads - -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.lightning.wire.LiquidityAds -import fr.acinq.phoenix.db.migrations.v10.json.ByteVector32Serializer -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 deleted file mode 100644 index 82763106..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/migrations/v11/types/liquidityads/PurchaseData.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file: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.lightning.wire.LiquidityAds -import fr.acinq.phoenix.db.migrations.v10.json.MilliSatoshiSerializer -import fr.acinq.phoenix.db.migrations.v10.json.SatoshiSerializer -import fr.acinq.phoenix.db.migrations.v11.types.liquidityads.PaymentDetailsData.Companion.asCanonical -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 deleted file mode 100644 index 2c0804ab..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/notifications/NotificationDataType.kt +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright 2023 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@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.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 deleted file mode 100644 index 49d8f75c..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/notifications/NotificationsQueries.kt +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright 2023 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.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.notifications.NotificationData.Companion.encodeAsDb -import fr.acinq.phoenix.db.sqldelight.AppDatabase -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 deleted file mode 100644 index 5b32d480..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/CloudKitInterface.kt +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index 46ee4c2c..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/MetadataTypes.kt +++ /dev/null @@ -1,330 +0,0 @@ -package fr.acinq.phoenix.db.payments - -import fr.acinq.bitcoin.ByteVector -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.phoenix.data.ExchangeRate -import fr.acinq.phoenix.data.FiatCurrency -import fr.acinq.phoenix.data.LnurlPayMetadata -import fr.acinq.phoenix.data.WalletPaymentMetadata -import fr.acinq.phoenix.data.lnurl.LnurlPay -import fr.acinq.phoenix.db.cloud.cborSerializer -import io.ktor.http.Url -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.cbor.ByteString -import kotlinx.serialization.cbor.Cbor -import kotlinx.serialization.decodeFromByteArray -import kotlinx.serialization.encodeToByteArray - -/** - * 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 deleted file mode 100644 index 688c0ac1..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/PaymentsMetadataQueries.kt +++ /dev/null @@ -1,155 +0,0 @@ -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.LnurlPayMetadata -import fr.acinq.phoenix.data.WalletPaymentMetadata -import fr.acinq.phoenix.data.lnurl.LnurlPay -import fr.acinq.phoenix.db.didUpdateWalletPaymentMetadata -import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase -import io.ktor.http.Url - -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 deleted file mode 100644 index f7016a98..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/SqliteIncomingPaymentsDb.kt +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.db.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.didDeleteWalletPayment -import fr.acinq.phoenix.db.didSaveWalletPayment -import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase -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 deleted file mode 100644 index c0c58e9a..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/payments/SqliteOutgoingPaymentsDb.kt +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.db.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.didSaveWalletPayment -import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase -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 deleted file mode 100644 index 2d90875e..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/serialization/contacts/Serialization.kt +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index d8e3a65c..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/serialization/contacts/v1/Deserialization.kt +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index 85cdca52..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/db/serialization/contacts/v1/Serialization.kt +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 59922d66..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/AppConfigurationManager.kt +++ /dev/null @@ -1,96 +0,0 @@ -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) { - _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 deleted file mode 100644 index ff9f6987..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/AppConnectionsDaemon.kt +++ /dev/null @@ -1,468 +0,0 @@ -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.logging.debug -import fr.acinq.lightning.logging.error -import fr.acinq.lightning.logging.info -import fr.acinq.lightning.utils.Connection -import fr.acinq.lightning.utils.ServerAddress -import fr.acinq.phoenix.PhoenixBusiness -import fr.acinq.phoenix.PhoenixGlobal -import fr.acinq.phoenix.data.ElectrumConfig -import fr.acinq.phoenix.managers.global.NetworkState -import fr.acinq.phoenix.utils.extensions.isOnion -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.channels.consumeEach -import kotlinx.coroutines.delay -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 kotlinx.coroutines.launch -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 deleted file mode 100644 index 0c3d3d9a..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/BalanceManager.kt +++ /dev/null @@ -1,102 +0,0 @@ -package fr.acinq.phoenix.managers - -import fr.acinq.bitcoin.Satoshi -import fr.acinq.lightning.MilliSatoshi -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.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.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.filterNotNull -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 deleted file mode 100644 index 5543169b..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/ConnectionsManager.kt +++ /dev/null @@ -1,68 +0,0 @@ -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 fr.acinq.phoenix.utils.extensions.plus -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.stateIn - -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 deleted file mode 100644 index 4bc52179..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/DataStoreManager.kt +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.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 deleted file mode 100644 index 1a135ff8..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/DatabaseManager.kt +++ /dev/null @@ -1,129 +0,0 @@ -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 deleted file mode 100644 index 9689991c..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/LnurlManager.kt +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright 2021 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.managers - -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.lightning.logging.error -import fr.acinq.lightning.payment.PaymentRequest -import fr.acinq.phoenix.PhoenixBusiness -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 io.ktor.client.HttpClient -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.request.get -import io.ktor.client.statement.HttpResponse -import io.ktor.http.URLBuilder -import io.ktor.http.Url -import io.ktor.serialization.kotlinx.json.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 deleted file mode 100644 index 71cb8eb9..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/NodeParamsManager.kt +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright 2021 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.managers - -import fr.acinq.bitcoin.Chain -import fr.acinq.bitcoin.PublicKey -import fr.acinq.lightning.NodeParams -import fr.acinq.lightning.NodeUri -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.lightning.logging.info -import fr.acinq.lightning.payment.LiquidityPolicy -import fr.acinq.lightning.utils.msat -import fr.acinq.lightning.utils.sat -import fr.acinq.phoenix.PhoenixBusiness -import fr.acinq.phoenix.data.OfferData -import fr.acinq.phoenixd.conf.LSP -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.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch - - -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 deleted file mode 100644 index 4a5a140e..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/NotificationsManager.kt +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright 2023 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.managers - -import fr.acinq.lightning.LiquidityEvents -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.lightning.logging.debug -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 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 deleted file mode 100644 index 0a6da06e..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PaymentMetadataQueue.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.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 deleted file mode 100644 index 8acf58fe..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PaymentsManager.kt +++ /dev/null @@ -1,121 +0,0 @@ -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.UUID -import fr.acinq.phoenix.PhoenixBusiness -import fr.acinq.phoenix.data.WalletPaymentInfo -import fr.acinq.phoenix.data.WalletPaymentMetadata -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 deleted file mode 100644 index cf63719e..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PaymentsPageFetcher.kt +++ /dev/null @@ -1,232 +0,0 @@ -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.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import kotlin.time.Clock -import kotlin.time.Duration.Companion.seconds -import kotlin.time.ExperimentalTime -import kotlin.time.Instant - -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 deleted file mode 100644 index e0b0599a..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PeerManager.kt +++ /dev/null @@ -1,297 +0,0 @@ -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.logging.debug -import fr.acinq.lightning.logging.error -import fr.acinq.lightning.logging.info -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.managers.global.FeerateManager -import fr.acinq.phoenix.utils.extensions.isTerminated -import fr.acinq.phoenix.utils.extensions.nextTimeout -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - - -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 deleted file mode 100644 index 806cbabd..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/PinManager.kt +++ /dev/null @@ -1,142 +0,0 @@ -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.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.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.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.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.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 deleted file mode 100644 index 03cdec8b..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/SeedManager.kt +++ /dev/null @@ -1,183 +0,0 @@ -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.managers.SeedManager.loadAndDecrypt -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. - */ - 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) - } - - class 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 deleted file mode 100644 index b6283a19..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/SendManager.kt +++ /dev/null @@ -1,665 +0,0 @@ -package fr.acinq.phoenix.managers - -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.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() { - class UnknownFormat : BadRequestReason() - class AlreadyPaidInvoice : BadRequestReason() - class 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 deleted file mode 100644 index 5413c7d4..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/WalletManager.kt +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright 2022 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.managers - -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.Chain -import fr.acinq.bitcoin.Crypto -import fr.acinq.bitcoin.KeyPath -import fr.acinq.bitcoin.MnemonicCode -import fr.acinq.bitcoin.PrivateKey -import fr.acinq.bitcoin.PublicKey -import fr.acinq.bitcoin.byteVector -import fr.acinq.lightning.crypto.Bip84OnChainKeys -import fr.acinq.lightning.crypto.LocalKeyManager -import fr.acinq.lightning.crypto.div -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -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 -} - -/** Key used to encrypt/decrypt blobs we store in the cloud. */ -fun LocalKeyManager.nostrPrivateKey(): PrivateKey { - val path = KeyPath(if (isMainnet()) "m/44'/1237'/0'/0/0" else "m/44'/1237'/1'/0/0") - return derivePrivateKey(path).privateKey -} - -fun PrivateKey.nsecPassword(): String { - return Crypto.hash160( - value - ).byteVector().toHex() -} - -fun LocalKeyManager.nsecPassword(): String = nostrPrivateKey().nsecPassword() - -fun LocalKeyManager.nostrPublicKey(): HexKey { - return KeyPair( - privKey = nostrPrivateKey().value.toByteArray() - ).pubKey.toHexKey() -} - -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 deleted file mode 100644 index b9b6eb40..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/CurrencyManager.kt +++ /dev/null @@ -1,423 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.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.Duration -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds -import kotlin.time.ExperimentalTime -import kotlin.time.Instant - -/** - * 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.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.fromEpochMilliseconds(it.timestampMillis) - } ?: run { - Instant.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.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.fromEpochMilliseconds(0), - nextRefresh = Instant.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 deleted file mode 100644 index 84daf3fe..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/FeerateManager.kt +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.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 deleted file mode 100644 index 5c2fa653..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/NetworkMonitor.kt +++ /dev/null @@ -1,18 +0,0 @@ -package fr.acinq.phoenix.managers.global - -import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.phoenix.utils.PlatformContext -import kotlinx.coroutines.flow.StateFlow - -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 deleted file mode 100644 index c2caa318..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/WalletContextManager.kt +++ /dev/null @@ -1,190 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.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 deleted file mode 100644 index ed60836e..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/BlockchainInfoApi.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.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 deleted file mode 100644 index d8c85b53..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/BluelyticsApi.kt +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.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 deleted file mode 100644 index e870d004..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/CoinbaseApi.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.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.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 deleted file mode 100644 index 1318ecf2..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/ExchangeRateApi.kt +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.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 deleted file mode 100644 index 96bf235e..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/managers/global/fiatapis/YadioApi.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.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 deleted file mode 100644 index 1085b10b..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedData.kt +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.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 deleted file mode 100644 index fddbeaaf..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedPin.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.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 deleted file mode 100644 index ae4615b8..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedPinLock.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2021 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.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) - when (val version = stream.readByte()) { - 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 deleted file mode 100644 index 5c3308b1..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedPinSpending.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2021 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.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) - when (val version = stream.readByte()) { - 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 deleted file mode 100644 index 93a39285..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedSeed.kt +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright 2021 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.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 deleted file mode 100644 index e6a75d66..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/KeyStoreFunctions.kt +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 8e8e786f..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/security/KeyStoreNames.kt +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 78dcbf06..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/BlockchainExplorer.kt +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 1df9c161..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/Cache.kt +++ /dev/null @@ -1,248 +0,0 @@ -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) { - - 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) { - - _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() { - - 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 deleted file mode 100644 index 754c6ef5..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/DateUtils.kt +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 2fd2d8be..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/DnsResolvers.kt +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils - -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.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 deleted file mode 100644 index cfe7a78a..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/MnemonicLanguage.kt +++ /dev/null @@ -1,83 +0,0 @@ -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 deleted file mode 100644 index 4e023570..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/Parser.kt +++ /dev/null @@ -1,231 +0,0 @@ -/* - * Copyright 2022 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils - -import fr.acinq.bitcoin.Bitcoin -import fr.acinq.bitcoin.BitcoinError -import fr.acinq.bitcoin.ByteVector -import fr.acinq.bitcoin.Chain -import fr.acinq.bitcoin.Script -import fr.acinq.bitcoin.byteVector -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.BitcoinUri -import fr.acinq.phoenix.data.BitcoinUriError -import fr.acinq.phoenix.data.lnurl.Lnurl -import io.ktor.http.ParametersBuilder -import io.ktor.http.Url -import io.ktor.util.filter - -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 deleted file mode 100644 index c034b0c4..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/PlatformContext.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2022 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.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 deleted file mode 100644 index fcc56d65..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/channels/ChannelsImportHelper.kt +++ /dev/null @@ -1,68 +0,0 @@ -package fr.acinq.phoenix.utils.channels - -import fr.acinq.lightning.channel.states.PersistedChannelState - - -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 deleted file mode 100644 index 24dc35a9..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/channels/SpendChannelAddressHelper.kt +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils.channels - -import fr.acinq.bitcoin.ByteVector -import fr.acinq.bitcoin.ByteVector64 -import fr.acinq.bitcoin.PublicKey -import fr.acinq.bitcoin.TxId - -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 deleted file mode 100644 index cf2a75f8..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/converters/AmountConverter.kt +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils.converters - -import 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 deleted file mode 100644 index a4a69374..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/converters/AmountFormatter.kt +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright 2019 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils.converters - - -import fr.acinq.bitcoin.Satoshi -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.utils.toMilliSatoshi -import fr.acinq.phoenix.data.BitcoinUnit -import fr.acinq.phoenix.data.CurrencyUnit -import fr.acinq.phoenix.data.ExchangeRate -import fr.acinq.phoenix.data.FiatCurrency -import fr.acinq.phoenix.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 deleted file mode 100644 index fc496516..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/ChainExtensions.kt +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 17bd0973..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/ChannelExtensions.kt +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright 2022 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils.extensions - -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.channel.Commitment -import fr.acinq.lightning.channel.states.Aborted -import fr.acinq.lightning.channel.states.ChannelState -import fr.acinq.lightning.channel.states.ChannelStateWithCommitments -import fr.acinq.lightning.channel.states.Closed -import fr.acinq.lightning.channel.states.Closing -import fr.acinq.lightning.channel.states.Negotiating -import fr.acinq.lightning.channel.states.Offline -import fr.acinq.lightning.channel.states.Syncing -import fr.acinq.lightning.channel.states.WaitForAcceptChannel -import fr.acinq.lightning.channel.states.WaitForChannelReady -import fr.acinq.lightning.channel.states.WaitForFundingConfirmed -import fr.acinq.lightning.channel.states.WaitForFundingCreated -import fr.acinq.lightning.channel.states.WaitForFundingSigned -import fr.acinq.lightning.channel.states.WaitForInit -import fr.acinq.lightning.channel.states.WaitForOpenChannel -import fr.acinq.lightning.channel.states.WaitForRemotePublishFutureCommitment -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 deleted file mode 100644 index 41952884..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/ConnectionExtensions.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2022 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.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 deleted file mode 100644 index fa48efb9..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/MiscExtensions.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils.extensions - -import 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 deleted file mode 100644 index 3a238dd5..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/PaymentExtensions.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2022 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@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 deleted file mode 100644 index 9e7e5515..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/PaymentRequestExtensions.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2023 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils.extensions - -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 deleted file mode 100644 index 69f29a7a..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils.extensions - -import fr.acinq.phoenix.data.DecryptSeedResult - - -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 deleted file mode 100644 index 63056001..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/extensions/WalletStateExtensions.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2023 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils.extensions - -import fr.acinq.lightning.SwapInParams -import fr.acinq.lightning.blockchain.electrum.WalletState -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 deleted file mode 100644 index dc75c5e0..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/logger/LoggerConfig.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2024 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils.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 deleted file mode 100644 index 5ffe81ff..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/migrations/IosMigrationHelper.kt +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Copyright 2023 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils.migrations - -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.TxId -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.blockchain.fee.FeeratePerKw -import fr.acinq.lightning.channel.ChannelCommand -import fr.acinq.lightning.channel.states.ChannelState -import fr.acinq.lightning.channel.states.ChannelStateWithCommitments -import fr.acinq.lightning.channel.states.Closed -import fr.acinq.lightning.channel.states.Closing -import fr.acinq.lightning.channel.states.Negotiating -import fr.acinq.lightning.channel.states.Offline -import fr.acinq.lightning.channel.states.ShuttingDown -import fr.acinq.lightning.channel.states.Syncing -import fr.acinq.lightning.io.WrappedChannelCommand -import fr.acinq.lightning.logging.info -import fr.acinq.lightning.logging.warning -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.managers.phoenixSwapInWallet -import fr.acinq.phoenix.utils.Parser -import fr.acinq.phoenix.utils.extensions.isBeingCreated -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 deleted file mode 100644 index d084b822..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/migrations/LegacyChannelCloseHelper.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright 2023 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.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 deleted file mode 100644 index 578e85c0..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/GlobalPrefs.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils.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.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 deleted file mode 100644 index d66b65c3..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/InternalPrefs.kt +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright 2023 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.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") - 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 deleted file mode 100644 index f9e47577..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/UserPrefs.kt +++ /dev/null @@ -1,389 +0,0 @@ -/* - * Copyright 2022 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.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 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.Serializable -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 deleted file mode 100644 index 909de4db..00000000 --- a/composeApp/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/UserPrefsComposeExtensions.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2025 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.utils.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 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/iosMain/kotlin/fr/acinq/phoenix/data/ElectrumServers.ios.kt b/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/data/ElectrumServers.ios.kt deleted file mode 100644 index 8e918e66..00000000 --- a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/data/ElectrumServers.ios.kt +++ /dev/null @@ -1,6 +0,0 @@ -package fr.acinq.phoenix.data - -import fr.acinq.lightning.io.TcpSocket -import fr.acinq.lightning.utils.ServerAddress - -actual fun platformElectrumRegtestConf(): ServerAddress = ServerAddress(host = "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 deleted file mode 100644 index 2bfa6238..00000000 --- a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitContactsDb.kt +++ /dev/null @@ -1,319 +0,0 @@ -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 kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -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 deleted file mode 100644 index 7d16617e..00000000 --- a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitDb.kt +++ /dev/null @@ -1,14 +0,0 @@ -package fr.acinq.phoenix.db - -import fr.acinq.phoenix.db.payments.CloudKitInterface -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.MainScope - -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 deleted file mode 100644 index 438163ab..00000000 --- a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitPaymentsDb.kt +++ /dev/null @@ -1,385 +0,0 @@ -package fr.acinq.phoenix.db - -import app.cash.sqldelight.Transacter -import app.cash.sqldelight.coroutines.asFlow -import fr.acinq.lightning.db.IncomingPayment -import fr.acinq.lightning.db.OutgoingPayment -import fr.acinq.lightning.db.WalletPayment -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.SqliteIncomingPaymentsDb -import fr.acinq.phoenix.db.payments.SqliteOutgoingPaymentsDb -import fr.acinq.phoenix.db.payments.WalletPaymentMetadataRow -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -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 deleted file mode 100644 index 8851c1fe..00000000 --- a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/DbFactory.ios.kt +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index c40f5a15..00000000 --- a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/db/DbHooks.ios.kt +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 0f6f6078..00000000 --- a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/ios/BusinessManager.kt +++ /dev/null @@ -1,311 +0,0 @@ -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.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 - -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 { (_, 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) - } - } - 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 deleted file mode 100644 index 6370f333..00000000 --- a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/managers/DataStoreManager.ios.kt +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 14da60ef..00000000 --- a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/managers/global/NetworkMonitor.ios.kt +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index b0b6bcba..00000000 --- a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/security/CCCipher.kt +++ /dev/null @@ -1,210 +0,0 @@ -package fr.acinq.phoenix.security - -import kotlinx.cinterop.ByteVar -import kotlinx.cinterop.CValuesRef -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.MemScope -import kotlinx.cinterop.addressOf -import kotlinx.cinterop.alloc -import kotlinx.cinterop.convert -import kotlinx.cinterop.memScoped -import kotlinx.cinterop.pin -import kotlinx.cinterop.ptr -import kotlinx.cinterop.refTo -import kotlinx.cinterop.value -import platform.CoreCrypto.CCAlgorithm -import platform.CoreCrypto.CCCryptorCreateWithMode -import platform.CoreCrypto.CCCryptorFinal -import platform.CoreCrypto.CCCryptorGetOutputLength -import platform.CoreCrypto.CCCryptorRefVar -import platform.CoreCrypto.CCCryptorRelease -import platform.CoreCrypto.CCCryptorStatus -import platform.CoreCrypto.CCCryptorUpdate -import platform.CoreCrypto.CCMode -import platform.CoreCrypto.CCOperation -import platform.CoreCrypto.CCPadding -import platform.CoreCrypto.ccPKCS7Padding -import platform.CoreCrypto.kCCAlgorithmAES -import platform.CoreCrypto.kCCAlignmentError -import platform.CoreCrypto.kCCBufferTooSmall -import platform.CoreCrypto.kCCDecodeError -import platform.CoreCrypto.kCCDecrypt -import platform.CoreCrypto.kCCEncrypt -import platform.CoreCrypto.kCCMemoryFailure -import platform.CoreCrypto.kCCModeCBC -import platform.CoreCrypto.kCCParamError -import platform.CoreCrypto.kCCSuccess -import platform.CoreCrypto.kCCUnimplemented -import platform.posix.size_tVar - -@OptIn(ExperimentalForeignApi::class) -private val almostEmptyArrayPinned = ByteArray(1).pin() - -@ExperimentalForeignApi -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 deleted file mode 100644 index c211bfee..00000000 --- a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/security/KeyChainHelper.kt +++ /dev/null @@ -1,259 +0,0 @@ -package fr.acinq.phoenix.security - -import co.touchlab.kermit.Logger -import com.machankura.compose.AppVersion -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.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 deleted file mode 100644 index 0d4fba13..00000000 --- a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/security/KeyStoreFunctions.ios.kt +++ /dev/null @@ -1,25 +0,0 @@ -package fr.acinq.phoenix.security - -import fr.acinq.lightning.Lightning - -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 deleted file mode 100644 index 7e0e1e4d..00000000 --- a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/utils/PlatformContext.ios.kt +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index cdec6348..00000000 --- a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.ios.kt +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 54ff7126..00000000 --- a/composeApp/src/iosMain/kotlin/fr/acinq/phoenix/utils/logger/LoggerConfig.ios.kt +++ /dev/null @@ -1,15 +0,0 @@ -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