From 5210a0eb254eee5d0bd4b79c2c6716abcdafd769 Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Wed, 18 Sep 2024 13:25:23 +0200 Subject: [PATCH 1/7] Upgrade to kotlin v2.0.10 Also upgraded: - ktor to 2.3.12 - kotlinx.serialisation to 1.7.2 (transitive) - kotlinx.coroutine to 1.9.0 (transitive) - kermit to 2.0.4 (transitive) - skie to 0.8.4 - sqldelight to 2.0.2 --- .gitignore | 4 ++-- buildSrc/src/main/kotlin/Versions.kt | 8 ++++---- phoenix-shared/build.gradle.kts | 4 ++-- .../managers/AppConfigurationManager.kt | 3 ++- .../fr.acinq.phoenix/managers/NodeParamsManager.kt | 2 +- .../fr.acinq.phoenix/utils/BlockchainExplorer.kt | 12 ++++++++---- .../utils/extensions/PaymentRequestExtensions.kt | 2 +- 7 files changed, 20 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 2feb7b609..9b997370f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Build build/ +.cxx +.kotlin # Idea .idea @@ -24,8 +26,6 @@ sdk-dependencies apk phoenix-legacy/release phoenix-legacy/debug -.cxx # LangTool .langtool/deepl.authtoken - diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 5e1c26379..7d1a9c41a 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -1,12 +1,12 @@ object Versions { - const val lightningKmp = "1.7.3" + const val lightningKmp = "1.7.4-SNAPSHOT" const val secp256k1 = "0.14.0" const val torMobile = "0.2.0" - const val kotlin = "1.9.22" + const val kotlin = "2.0.10" - const val ktor = "2.3.7" - const val sqlDelight = "2.0.1" + const val ktor = "2.3.12" + const val sqlDelight = "2.0.2" const val slf4j = "1.7.30" const val junit = "4.13" diff --git a/phoenix-shared/build.gradle.kts b/phoenix-shared/build.gradle.kts index 8f57b666a..22e94b90f 100644 --- a/phoenix-shared/build.gradle.kts +++ b/phoenix-shared/build.gradle.kts @@ -11,7 +11,7 @@ plugins { if (System.getProperty("includeAndroid")?.toBoolean() == true) { id("com.android.library") } - id("co.touchlab.skie") version "0.8.1" + id("co.touchlab.skie") version "0.8.4" } val includeAndroid = System.getProperty("includeAndroid")?.toBoolean() ?: false @@ -99,7 +99,7 @@ kotlin { implementation("app.cash.sqldelight:runtime:${Versions.sqlDelight}") implementation("app.cash.sqldelight:coroutines-extensions:${Versions.sqlDelight}") // SKEI - implementation("co.touchlab.skie:configuration-annotations:0.8.1") + implementation("co.touchlab.skie:configuration-annotations:0.8.4") } } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/AppConfigurationManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/AppConfigurationManager.kt index 92e189ded..9db9ad87c 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/AppConfigurationManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/AppConfigurationManager.kt @@ -218,7 +218,8 @@ class AppConfigurationManager( fun randomElectrumServer() = when (chain) { Chain.Mainnet -> mainnetElectrumServers.random() - Chain.Testnet -> testnetElectrumServers.random() + Chain.Testnet3 -> testnetElectrumServers.random() + Chain.Testnet4 -> TODO() Chain.Signet -> TODO() Chain.Regtest -> platformElectrumRegtestConf() } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NodeParamsManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NodeParamsManager.kt index a83405307..72adb7050 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NodeParamsManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NodeParamsManager.kt @@ -88,7 +88,7 @@ class NodeParamsManager( } companion object { - val chain = Chain.Testnet + val chain = Chain.Testnet3 val trampolineNodeId = PublicKey.fromHex("03933884aaf1d6b108397e5efe5c86bcf2d8ca8d2f700eda99db9214fc2712b134") val trampolineNodeUri = NodeUri(id = trampolineNodeId, "13.248.222.197", 9735) const val remoteSwapInXpub = "tpubDAmCFB21J9ExKBRPDcVxSvGs9jtcf8U1wWWbS1xTYmnUsuUHPCoFdCnEGxLE3THSWcQE48GHJnyz8XPbYUivBMbLSMBifFd3G9KmafkM9og" diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/BlockchainExplorer.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/BlockchainExplorer.kt index 4c441e9c8..78dcbf06f 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/BlockchainExplorer.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/BlockchainExplorer.kt @@ -16,7 +16,8 @@ class BlockchainExplorer(private val chain: Chain) { Website.MempoolSpace -> { when (chain) { Chain.Mainnet -> "${website.base}/tx/$txId" - Chain.Testnet -> "${website.base}/testnet/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" } @@ -24,7 +25,8 @@ class BlockchainExplorer(private val chain: Chain) { Website.BlockstreamInfo -> { when (chain) { Chain.Mainnet -> "${website.base}/tx/$txId" - Chain.Testnet -> "${website.base}/testnet/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" } @@ -37,7 +39,8 @@ class BlockchainExplorer(private val chain: Chain) { Website.MempoolSpace -> { when (chain) { Chain.Mainnet -> "${website.base}/address/$addr" - Chain.Testnet -> "${website.base}/testnet/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" } @@ -45,7 +48,8 @@ class BlockchainExplorer(private val chain: Chain) { Website.BlockstreamInfo -> { when (chain) { Chain.Mainnet -> "${website.base}/address/$addr" - Chain.Testnet -> "${website.base}/testnet/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" } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentRequestExtensions.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentRequestExtensions.kt index d191caa0e..e29b00379 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentRequestExtensions.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentRequestExtensions.kt @@ -36,7 +36,7 @@ val PaymentRequest.chain: Chain is Bolt11Invoice -> { when (prefix) { "lnbc" -> Chain.Mainnet - "lntb" -> Chain.Testnet + "lntb" -> Chain.Testnet3 "lnbcrt" -> Chain.Regtest else -> throw IllegalArgumentException("unhandled invoice prefix=$prefix") } From 40e323b65710b16d872d3b77c59adcfe3bfa5056 Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Wed, 18 Sep 2024 13:30:28 +0200 Subject: [PATCH 2/7] (android) Migrate to compose compiler gradle plugin This is required for kotlin 2+. --- buildSrc/src/main/kotlin/Versions.kt | 1 - phoenix-android/build.gradle.kts | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 7d1a9c41a..bd94e8460 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -19,7 +19,6 @@ object Versions { const val prefs = "1.2.0" const val datastore = "1.0.0" const val compose = "1.6.2" - const val composeCompiler = "1.5.8" const val navCompose = "2.6.0" const val accompanist = "0.30.1" const val composeConstraintLayout = "1.1.0-alpha09" diff --git a/phoenix-android/build.gradle.kts b/phoenix-android/build.gradle.kts index 12ee1c377..97478aa29 100644 --- a/phoenix-android/build.gradle.kts +++ b/phoenix-android/build.gradle.kts @@ -5,6 +5,7 @@ plugins { kotlin("android") id("com.google.gms.google-services") id("kotlinx-serialization") + id("org.jetbrains.kotlin.plugin.compose") version Versions.kotlin } fun gitCommitHash(): String { @@ -71,10 +72,6 @@ android { dataBinding = true } - composeOptions { - kotlinCompilerExtensionVersion = Versions.Android.composeCompiler - } - packagingOptions { resources.merges.add("reference.conf") } From 971ad20a14d5e341eb4bf275d18ece307efd7c9a Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Wed, 18 Sep 2024 13:31:34 +0200 Subject: [PATCH 3/7] (ios) Test disabling the special memory allocation This rule seems to cause a build issue with kotlin 2.0. --- phoenix-shared/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phoenix-shared/build.gradle.kts b/phoenix-shared/build.gradle.kts index 22e94b90f..803cf9f5b 100644 --- a/phoenix-shared/build.gradle.kts +++ b/phoenix-shared/build.gradle.kts @@ -71,7 +71,7 @@ kotlin { // The notification-service-extension is limited to 24 MB of memory. // With mimalloc we can easily hit the 24 MB limit, and the OS kills the process. // But with standard allocation, we're using less then half the limit. - kotlinOptions.freeCompilerArgs += "-Xallocator=std" + //kotlinOptions.freeCompilerArgs += "-Xallocator=std" kotlinOptions.freeCompilerArgs += listOf("-linker-options", "-application_extension") // workaround for xcode 15 and kotlin < 1.9.10: // https://youtrack.jetbrains.com/issue/KT-60230/Native-unknown-options-iossimulatorversionmin-sdkversion-with-Xcode-15-beta-3 From f91f256b050febd0db432d65ead6403fa4fbb94b Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Wed, 18 Sep 2024 15:59:33 +0200 Subject: [PATCH 4/7] Fix chain in phoenix-ios and tests We are for now using Testnet3. --- .../phoenix-ios/officers/BusinessManager.swift | 2 +- .../fr/acinq/phoenix/data/lnurl/LnurlAuthTest.kt | 2 +- .../acinq/phoenix/db/SqlitePaymentsDatabaseTest.kt | 2 +- .../fr/acinq/phoenix/utils/CsvWriterTests.kt | 2 +- .../kotlin/fr/acinq/phoenix/utils/ParserTest.kt | 14 +++++++------- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/phoenix-ios/phoenix-ios/officers/BusinessManager.swift b/phoenix-ios/phoenix-ios/officers/BusinessManager.swift index 7d9d04767..530fd28fd 100644 --- a/phoenix-ios/phoenix-ios/officers/BusinessManager.swift +++ b/phoenix-ios/phoenix-ios/officers/BusinessManager.swift @@ -93,7 +93,7 @@ class BusinessManager { private init() { // must use shared instance business = PhoenixBusiness(ctx: PlatformContext.default) - BusinessManager._isTestnet = business.chain.isTestnet() + BusinessManager._isTestnet = business.chain.isTestnet3() let nc = NotificationCenter.default diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/data/lnurl/LnurlAuthTest.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/data/lnurl/LnurlAuthTest.kt index d29c6864c..8c621305a 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/data/lnurl/LnurlAuthTest.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/data/lnurl/LnurlAuthTest.kt @@ -27,7 +27,7 @@ import kotlin.test.assertEquals class LnurlAuthTest { private val mnemonics = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" private val seed = MnemonicCode.toSeed(mnemonics, passphrase = "").toByteVector() - private val keyManager = LocalKeyManager(seed, Chain.Testnet, remoteSwapInExtendedPublicKey = "tpubDDt5vQap1awkyDXx1z1cP7QFKSZHDCCpbU8nSq9jy7X2grTjUVZDePexf6gc6AHtRRzkgfPW87K6EKUVV6t3Hu2hg7YkHkmMeLSfrP85x41") + private val keyManager = LocalKeyManager(seed, Chain.Testnet3, remoteSwapInExtendedPublicKey = "tpubDDt5vQap1awkyDXx1z1cP7QFKSZHDCCpbU8nSq9jy7X2grTjUVZDePexf6gc6AHtRRzkgfPW87K6EKUVV6t3Hu2hg7YkHkmMeLSfrP85x41") @Test fun specs_test_vectors() { diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/SqlitePaymentsDatabaseTest.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/SqlitePaymentsDatabaseTest.kt index aef6f06b9..676165fde 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/SqlitePaymentsDatabaseTest.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/SqlitePaymentsDatabaseTest.kt @@ -154,7 +154,7 @@ class SqlitePaymentsDatabaseTest { fun incoming__purge_expired() = runTest { val expiredPreimage = randomBytes32() val expiredInvoice = Bolt11Invoice.create( - chain = Chain.Testnet, + chain = Chain.Testnet3, amount = 150_000.msat, paymentHash = Crypto.sha256(expiredPreimage).toByteVector32(), privateKey = Lightning.randomKey(), diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/CsvWriterTests.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/CsvWriterTests.kt index 3fabedb5c..5494afe67 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/CsvWriterTests.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/CsvWriterTests.kt @@ -337,7 +337,7 @@ class CsvWriterTests { */ private fun makePaymentRequest() = Bolt11Invoice.create( - chain = Chain.Testnet, + chain = Chain.Testnet3, amount = 10_000.msat, paymentHash = randomBytes32(), privateKey = PrivateKey(value = randomBytes32()), diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/ParserTest.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/ParserTest.kt index df2e1a878..4c177c5d8 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/ParserTest.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/ParserTest.kt @@ -41,18 +41,18 @@ class ParserTest { assertIs>(Parser.parseBip21Uri(Chain.Mainnet, "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")) assertIs>(Parser.parseBip21Uri(Chain.Mainnet, "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3")) - assertIs>(Parser.parseBip21Uri(Chain.Testnet, "mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn")) - assertIs>(Parser.parseBip21Uri(Chain.Testnet, "2MzQwSSnBHWHqSAqtTVQ6v47XtaisrJa1Vc")) - assertIs>(Parser.parseBip21Uri(Chain.Testnet, "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx")) - assertIs>(Parser.parseBip21Uri(Chain.Testnet, "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7")) - assertIs>(Parser.parseBip21Uri(Chain.Testnet, "tb1p607g5ea77m370pey3y5rg58fz7542hnpg40rs2cqw6w69yt5lf2qlktj2a")) + assertIs>(Parser.parseBip21Uri(Chain.Testnet3, "mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn")) + assertIs>(Parser.parseBip21Uri(Chain.Testnet3, "2MzQwSSnBHWHqSAqtTVQ6v47XtaisrJa1Vc")) + assertIs>(Parser.parseBip21Uri(Chain.Testnet3, "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx")) + assertIs>(Parser.parseBip21Uri(Chain.Testnet3, "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7")) + assertIs>(Parser.parseBip21Uri(Chain.Testnet3, "tb1p607g5ea77m370pey3y5rg58fz7542hnpg40rs2cqw6w69yt5lf2qlktj2a")) } @Test fun parse_bitcoin_uri_chain_mismatch() { assertEquals( expected = Either.Left(BitcoinUriError.InvalidScript(error = BitcoinError.ChainHashMismatch)), - actual = Parser.parseBip21Uri(Chain.Testnet, "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3") + actual = Parser.parseBip21Uri(Chain.Testnet3, "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3") ) assertEquals( expected = Either.Left(BitcoinUriError.InvalidScript(error = BitcoinError.ChainHashMismatch)), @@ -77,7 +77,7 @@ class ParserTest { ) assertIs>( Parser.parseBip21Uri( - Chain.Testnet, + Chain.Testnet3, "bitcoin:?lno=lno1qgsyxjtl6luzd9t3pr62xr7eemp6awnejusgf6gw45q75vcfqqqqqqqsespexwyy4tcadvgg89l9aljus6709kx235hhqrk6n8dey98uyuftzdqrt2gkjvf2rj2vnt7m7chnmazen8wpur2h65ttgftkqaugy6ql9dcsyq39xc2g084xfn0s50zlh2ex22vvaqxqz3vmudklz453nns4d0624sqr8ux4p5usm22qevld4ydfck7hwgcg9wc3f78y7jqhc6hwdq7e9dwkhty3svq5ju4dptxtldjumlxh5lw48jsz6pnagtwrmeus7uq9rc5g6uddwcwldpklxexvlezld8egntua4gsqqy8auz966nksacdac8yv3maq6elp" ) ) From c072ee4c0aa4f52f994172e3f82054086bd15e20 Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Wed, 18 Sep 2024 16:00:38 +0200 Subject: [PATCH 5/7] (ios) Remove script copying shared framework to derived data This script does not seem necessary anymore, as the framework copying is already done (require further investigation). --- .../phoenix-ios.xcodeproj/project.pbxproj | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj index 58c56c6b2..4168bf5ba 100644 --- a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj +++ b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj @@ -1615,7 +1615,6 @@ buildConfigurationList = DCB0DB8C255AE42F005B29C8 /* Build configuration list for PBXNativeTarget "phoenix-ios-framework" */; buildPhases = ( DCB0DB95255AE43E005B29C8 /* ShellScript */, - DCB0DB9F255AE6F1005B29C8 /* ShellScript */, ); buildRules = ( ); @@ -1782,27 +1781,6 @@ shellPath = /bin/sh; shellScript = "cd \"$SRCROOT/..\"\necho ./gradlew embedAndSignAppleFrameworkForXcode\n./gradlew embedAndSignAppleFrameworkForXcode\n"; }; - DCB0DB9F255AE6F1005B29C8 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "$SRCROOT/../phoenix-shared/build/xcode-frameworks/$CONFIGURATION/$SDK_NAME/PhoenixShared.framework", - "$SRCROOT/../phoenix-shared/build/xcode-frameworks/$CONFIGURATION/$SDK_NAME/PhoenixShared.framework.dSYM", - ); - outputFileListPaths = ( - ); - outputPaths = ( - $BUILT_PRODUCTS_DIR/$FULL_PRODUCT_NAME/PhoenixShared.framework, - $BUILT_PRODUCTS_DIR/$FULL_PRODUCT_NAME/PhoenixShared.framework.dSYM, - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "SrcFrmk=\"$SRCROOT/../phoenix-shared/build/xcode-frameworks/$CONFIGURATION/$SDK_NAME/PhoenixShared.framework\"\necho \"SrcFrmk = $SrcFrmk\"\n\nDstFrmk=\"$BUILT_PRODUCTS_DIR/$FULL_PRODUCT_NAME\"\necho \"DstFrmk = $DstFrmk\"\n\necho \"rm -R $DstFrmk/Frameworks\"\nrm -R \"$DstFrmk/Frameworks\"\n\necho \"cp -R $SrcFrmk/ $DstFrmk/\"\ncp -R \"$SrcFrmk/\" \"$DstFrmk/\"\n\necho \"cp -R $SrcFrmk.dSYM/ $DstFrmk.dSYM/\"\ncp -R \"$SrcFrmk.dSYM/\" \"$DstFrmk.dSYM/\"\n"; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ From 4a84362827b2fe605dd1997b7955e9603e70c9de Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Wed, 25 Sep 2024 15:50:05 +0200 Subject: [PATCH 6/7] Update phoenix-shared to new on-the-fly - Updated the liquidity policy (see NodeParamsManager). We are using a policy that does not target additional liquidity and that does not use fee credit yet. - Removed the code updating the peer's swap feerate (it's now provided by the LSP) - Updated the received-with database object with new types: incoming LN payments may contain a funding fee ; payments may be received through a fee credit (not enabled for now). - Updated the liquidity-purchase database objects with new purchase & payment-details type. The lease data are now legacy and are removed wherever possible. Note: for the inbound liquidity db objects, we are moving away from the type_version pattern for serialisation. Instead the json data type is embedded inside the json by the kotlinx serialisation library. This makes the code less verbose. - Added a liquidity purchase wrapper for cloud data. - Updated the Notification types with new rejection options. We try to match in this update the serialisation pattern that was mentioned above. --- .../fr/acinq/phoenix/db/androidDbFactory.kt | 13 +- .../fr.acinq.phoenix.db/Notifications.sq | 4 +- .../fr.acinq.phoenix/data/Notification.kt | 18 ++- .../kotlin/fr.acinq.phoenix/db/SqliteAppDb.kt | 3 - .../fr.acinq.phoenix/db/SqlitePaymentsDb.kt | 9 +- .../db/cloud/payments/CloudData.kt | 15 ++- .../InboundLiquidityPaymentWrapper.kt | 127 +++++++++--------- .../db/notifications/NotificationDataType.kt | 77 +++++------ .../db/notifications/NotificationsQueries.kt | 112 ++++++++------- .../db/payments/DbTypesHelper.kt | 1 + .../db/payments/InboundLiquidityLeaseType.kt | 104 -------------- .../db/payments/InboundLiquidityQueries.kt | 26 +++- .../db/payments/IncomingReceivedWithType.kt | 74 +++++++--- .../payments/liquidityads/FundingFeeData.kt | 28 ++++ .../payments/liquidityads/LegacyLeaseData.kt | 62 +++++++++ .../liquidityads/PaymentDetailsData.kt | 67 +++++++++ .../db/payments/liquidityads/PurchaseData.kt | 109 +++++++++++++++ .../db/serializers/v1/TxIdSerializer.kt | 25 ++++ .../managers/NodeParamsManager.kt | 32 ++--- .../managers/NotificationsManager.kt | 17 ++- .../fr.acinq.phoenix/managers/PeerManager.kt | 10 -- .../fr.acinq.phoenix/utils/CsvWriter.kt | 7 +- .../utils/extensions/PaymentExtensions.kt | 17 +++ .../InboundLiquidityOutgoing.sq | 14 +- .../fr.acinq.phoenix.db/migrations/9.sqm | 8 ++ .../db/IncomingPaymentDbTypeVersionTest.kt | 8 +- .../phoenix/db/SqlitePaymentsDatabaseTest.kt | 19 ++- .../acinq/phoenix/db/cloud/CloudDataTest.kt | 11 +- .../fr/acinq/phoenix/utils/CsvWriterTests.kt | 12 +- .../fr/acinq/phoenix/utils/ParserTest.kt | 49 +++---- .../fr/acinq/phoenix/db/iosDbFactory.kt | 12 +- 31 files changed, 697 insertions(+), 393 deletions(-) delete mode 100644 phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/InboundLiquidityLeaseType.kt create mode 100644 phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/FundingFeeData.kt create mode 100644 phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/LegacyLeaseData.kt create mode 100644 phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/PaymentDetailsData.kt create mode 100644 phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/PurchaseData.kt create mode 100644 phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serializers/v1/TxIdSerializer.kt create mode 100644 phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/migrations/9.sqm diff --git a/phoenix-shared/src/androidMain/kotlin/fr/acinq/phoenix/db/androidDbFactory.kt b/phoenix-shared/src/androidMain/kotlin/fr/acinq/phoenix/db/androidDbFactory.kt index 5f9f6d960..0167018d7 100644 --- a/phoenix-shared/src/androidMain/kotlin/fr/acinq/phoenix/db/androidDbFactory.kt +++ b/phoenix-shared/src/androidMain/kotlin/fr/acinq/phoenix/db/androidDbFactory.kt @@ -22,12 +22,19 @@ import fr.acinq.bitcoin.Chain import fr.acinq.phoenix.utils.PlatformContext actual fun createChannelsDbDriver(ctx: PlatformContext, chain: Chain, nodeIdHash: String): SqlDriver { - - return AndroidSqliteDriver(ChannelsDatabase.Schema, ctx.applicationContext, "channels-${chain.name.lowercase()}-$nodeIdHash.sqlite") + val chainName = when (chain) { + is Chain.Testnet3 -> "testnet" + else -> chain.name.lowercase() + } + return AndroidSqliteDriver(ChannelsDatabase.Schema, ctx.applicationContext, "channels-$chainName-$nodeIdHash.sqlite") } actual fun createPaymentsDbDriver(ctx: PlatformContext, chain: Chain, nodeIdHash: String): SqlDriver { - return AndroidSqliteDriver(PaymentsDatabase.Schema, ctx.applicationContext, "payments-${chain.name.lowercase()}-$nodeIdHash.sqlite") + val chainName = when (chain) { + is Chain.Testnet3 -> "testnet" + else -> chain.name.lowercase() + } + return AndroidSqliteDriver(PaymentsDatabase.Schema, ctx.applicationContext, "payments-$chainName-$nodeIdHash.sqlite") } actual fun createAppDbDriver(ctx: PlatformContext): SqlDriver { diff --git a/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/Notifications.sq b/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/Notifications.sq index b65844c00..525a5fb5e 100644 --- a/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/Notifications.sq +++ b/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/Notifications.sq @@ -1,5 +1,3 @@ -import fr.acinq.phoenix.db.notifications.NotificationTypeVersion; - -- This table stores notifications of all kinds. -- * id => UUID of a notification -- * type_version => string tracking the type/version of a notification @@ -8,7 +6,7 @@ import fr.acinq.phoenix.db.notifications.NotificationTypeVersion; -- * read_at => when the notification was read, in millis. Read notifications are typically not shown anymore. CREATE TABLE IF NOT EXISTS notifications ( id TEXT NOT NULL PRIMARY KEY, - type_version TEXT AS NotificationTypeVersion NOT NULL, + type_version TEXT NOT NULL, data_json BLOB NOT NULL, created_at INTEGER NOT NULL, read_at INTEGER DEFAULT NULL diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/Notification.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/Notification.kt index afb6f9265..bd675a454 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/Notification.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/Notification.kt @@ -63,7 +63,23 @@ sealed class Notification { override val source: LiquidityEvents.Source, ) : PaymentRejected() - data class ChannelsInitializing( + 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 ChannelFundingInProgress( + 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?, diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqliteAppDb.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqliteAppDb.kt index 89cecfc58..fc957fde1 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqliteAppDb.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqliteAppDb.kt @@ -26,9 +26,6 @@ class SqliteAppDb(private val driver: SqlDriver) { exchange_ratesAdapter = Exchange_rates.Adapter( typeAdapter = EnumColumnAdapter() ), - notificationsAdapter = Notifications.Adapter( - type_versionAdapter = EnumColumnAdapter() - ) ) private val priceQueries = database.exchangeRatesQueries diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqlitePaymentsDb.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqlitePaymentsDb.kt index dd327bda9..3a38b2be3 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqlitePaymentsDb.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqlitePaymentsDb.kt @@ -76,9 +76,6 @@ class SqlitePaymentsDb( channel_close_outgoing_paymentsAdapter = Channel_close_outgoing_payments.Adapter( closing_info_typeAdapter = EnumColumnAdapter() ), - inbound_liquidity_outgoing_paymentsAdapter = Inbound_liquidity_outgoing_payments.Adapter( - lease_typeAdapter = EnumColumnAdapter() - ) ) internal val inQueries = IncomingQueries(database) @@ -155,6 +152,12 @@ class SqlitePaymentsDb( } } + override suspend fun getInboundLiquidityPurchase(fundingTxId: TxId): InboundLiquidityOutgoingPayment? { + return withContext(Dispatchers.Default) { + inboundLiquidityQueries.getByTxId(fundingTxId) + } + } + override suspend fun completeOutgoingPaymentOffchain( id: UUID, finalFailure: FinalFailure, diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/CloudData.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/CloudData.kt index 66966d5cc..71678b417 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/CloudData.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/CloudData.kt @@ -1,6 +1,8 @@ package fr.acinq.phoenix.db.cloud import fr.acinq.lightning.db.* +import fr.acinq.phoenix.db.cloud.payments.InboundLiquidityLegacyWrapper +import fr.acinq.phoenix.db.cloud.payments.InboundLiquidityPaymentWrapper import kotlinx.serialization.* import kotlinx.serialization.cbor.ByteString import kotlinx.serialization.cbor.Cbor @@ -68,7 +70,9 @@ data class CloudData( @SerialName("sc") val spliceCpfp: SpliceCpfpPaymentWrapper? = null, @SerialName("il") - val inboundLiquidity: InboundLiquidityPaymentWrapper? = null, + val inboundLegacyLiquidity: InboundLiquidityLegacyWrapper? = null, + @SerialName("ip") + val inboundPurchaseLiquidity: InboundLiquidityPaymentWrapper? = null, @SerialName("v") val version: Int, @ByteString @@ -81,7 +85,8 @@ data class CloudData( spliceOutgoing = null, channelClose = null, spliceCpfp = null, - inboundLiquidity = null, + inboundLegacyLiquidity = null, + inboundPurchaseLiquidity = null, version = CloudDataVersion.V0.value, padding = ByteArray(size = 0) ) @@ -92,7 +97,8 @@ data class CloudData( spliceOutgoing = if (outgoing is SpliceOutgoingPayment) SpliceOutgoingPaymentWrapper(outgoing) else null, channelClose = if (outgoing is ChannelCloseOutgoingPayment) ChannelClosePaymentWrapper(outgoing) else null, spliceCpfp = if (outgoing is SpliceCpfpOutgoingPayment) SpliceCpfpPaymentWrapper(outgoing) else null, - inboundLiquidity = if (outgoing is InboundLiquidityOutgoingPayment) InboundLiquidityPaymentWrapper(outgoing) else null, + inboundLegacyLiquidity = null, + inboundPurchaseLiquidity = if (outgoing is InboundLiquidityOutgoingPayment) InboundLiquidityPaymentWrapper(outgoing) else null, version = CloudDataVersion.V0.value, padding = ByteArray(size = 0) ) @@ -112,7 +118,8 @@ data class CloudData( spliceOutgoing != null -> spliceOutgoing.unwrap() channelClose != null -> channelClose.unwrap() spliceCpfp != null -> spliceCpfp.unwrap() - inboundLiquidity != null -> inboundLiquidity.unwrap() + inboundLegacyLiquidity != null -> inboundLegacyLiquidity.unwrap() + inboundPurchaseLiquidity != null -> inboundPurchaseLiquidity.unwrap() else -> null } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/InboundLiquidityPaymentWrapper.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/InboundLiquidityPaymentWrapper.kt index 33c56ced1..5c5419c09 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/InboundLiquidityPaymentWrapper.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/InboundLiquidityPaymentWrapper.kt @@ -1,29 +1,30 @@ -package fr.acinq.phoenix.db.cloud +package fr.acinq.phoenix.db.cloud.payments import fr.acinq.bitcoin.TxId import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment import fr.acinq.lightning.utils.UUID -import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat -import fr.acinq.lightning.utils.toByteVector import fr.acinq.lightning.utils.toByteVector32 -import fr.acinq.lightning.utils.toByteVector64 import fr.acinq.lightning.wire.LiquidityAds +import fr.acinq.phoenix.db.cloud.UUIDSerializer +import fr.acinq.phoenix.db.payments.liquidityads.PurchaseData +import fr.acinq.phoenix.db.payments.liquidityads.PurchaseData.Companion.encodeAsDb import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.cbor.ByteString -@Serializable + +/** 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, + @ByteString val channelId: ByteArray, + @ByteString val txId: ByteArray, val miningFeesSat: Long, - val lease: LiquidityAdsLeaseWrapper, + val purchase: LiquidityAdsPurchaseWrapper, val createdAt: Long, val confirmedAt: Long?, val lockedAt: Long?, @@ -33,7 +34,7 @@ data class InboundLiquidityPaymentWrapper( channelId = src.channelId.toByteArray(), txId = src.txId.value.toByteArray(), miningFeesSat = src.miningFees.sat, - lease = LiquidityAdsLeaseWrapper(src.lease), + purchase = LiquidityAdsPurchaseWrapper(src.purchase), createdAt = src.createdAt, confirmedAt = src.confirmedAt, lockedAt = src.lockedAt @@ -45,80 +46,76 @@ data class InboundLiquidityPaymentWrapper( channelId = this.channelId.toByteVector32(), txId = TxId(this.txId), miningFees = this.miningFeesSat.sat, - lease = this.lease.unwrap(), + purchase = this.purchase.unwrap(), + createdAt = this.createdAt, + confirmedAt = this.confirmedAt, + lockedAt = this.lockedAt, + ) + + @Serializable + data class LiquidityAdsPurchaseWrapper(@ByteString val blob: ByteArray) { + companion object { + operator fun invoke(purchase: LiquidityAds.Purchase): LiquidityAdsPurchaseWrapper { + return LiquidityAdsPurchaseWrapper(purchase.encodeAsDb()) + } + } + 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() = InboundLiquidityOutgoingPayment( + id = this.id, + channelId = this.channelId.toByteVector32(), + txId = TxId(this.txId), + miningFees = this.miningFeesSat.sat, + purchase = this.lease.unwrap(), createdAt = this.createdAt, confirmedAt = this.confirmedAt, - lockedAt = this.lockedAt + lockedAt = this.lockedAt, ) @Serializable - @OptIn(ExperimentalSerializationApi::class) data class LiquidityAdsLeaseWrapper( val amountSat: Long, val fees: LiquidityAdsLeaseFeesWrapper, - @ByteString - val sellerSig: ByteArray, - val witness: LiquidityAdsLeaseWitnessWrapper ) { - constructor(src: LiquidityAds.Lease) : this( - amountSat = src.amount.sat, - fees = LiquidityAdsLeaseFeesWrapper(src.fees), - sellerSig = src.sellerSig.toByteArray(), - witness = LiquidityAdsLeaseWitnessWrapper(src.witness) - ) - @Throws(Exception::class) - fun unwrap() = LiquidityAds.Lease( - amount = this.amountSat.sat, - fees = this.fees.unwrap(), - sellerSig = this.sellerSig.toByteVector64(), - witness = this.witness.unwrap() - ) + 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 - @OptIn(ExperimentalSerializationApi::class) data class LiquidityAdsLeaseFeesWrapper( val miningFeeSat: Long, val serviceFeeSat: Long ) { - constructor(src: LiquidityAds.LeaseFees) : this( - miningFeeSat = src.miningFee.sat, - serviceFeeSat = src.serviceFee.sat - ) - @Throws(Exception::class) - fun unwrap() = LiquidityAds.LeaseFees( + fun unwrap() = LiquidityAds.Fees( miningFee = this.miningFeeSat.sat, serviceFee = this.serviceFeeSat.sat ) } - - @Serializable - @OptIn(ExperimentalSerializationApi::class) - data class LiquidityAdsLeaseWitnessWrapper( - @ByteString - val fundingScript: ByteArray, - val leaseDuration: Int, - val leaseEnd: Int, - val maxRelayFeeProportional: Int, - val maxRelayFeeBaseMsat: Long - ) { - constructor(src: LiquidityAds.LeaseWitness) : this( - fundingScript = src.fundingScript.toByteArray(), - leaseDuration = src.leaseDuration, - leaseEnd = src.leaseEnd, - maxRelayFeeProportional = src.maxRelayFeeProportional, - maxRelayFeeBaseMsat = src.maxRelayFeeBase.msat - ) - - @Throws(Exception::class) - fun unwrap() = LiquidityAds.LeaseWitness( - fundingScript = this.fundingScript.toByteVector(), - leaseDuration = this.leaseDuration, - leaseEnd = this.leaseEnd, - maxRelayFeeProportional = this.maxRelayFeeProportional, - maxRelayFeeBase = this.maxRelayFeeBaseMsat.msat - ) - } } \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/NotificationDataType.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/NotificationDataType.kt index 4aa1d1bde..b91cff6f5 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/NotificationDataType.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/NotificationDataType.kt @@ -32,26 +32,14 @@ import fr.acinq.phoenix.db.payments.DbTypesHelper import fr.acinq.phoenix.db.serializers.v1.ByteVector32Serializer import fr.acinq.phoenix.db.serializers.v1.MilliSatoshiSerializer import fr.acinq.phoenix.db.serializers.v1.SatoshiSerializer -import io.ktor.utils.io.charsets.* -import io.ktor.utils.io.core.* +import io.ktor.utils.io.charsets.Charsets +import io.ktor.utils.io.core.toByteArray import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -enum class NotificationTypeVersion { - PAYMENT_REJECTED_BY_USER_V0, - PAYMENT_REJECTED_TOO_EXPENSIVE_V0, - PAYMENT_REJECTED_OVER_ABSOLUTE_V0, - PAYMENT_REJECTED_OVER_RELATIVE_V0, - PAYMENT_REJECTED_DISABLED_V0, - PAYMENT_REJECTED_CHANNELS_INIT_V0, - - WATCH_TOWER_NOMINAL_V0, - WATCH_TOWER_UNKNOWN_V0, - WATCH_TOWER_REVOKED_FOUND_V0, -} - +@Serializable internal sealed class NotificationData { sealed class PaymentRejected : NotificationData() { sealed class OverAbsoluteFee : PaymentRejected() { @@ -79,16 +67,26 @@ internal sealed class NotificationData { data class V0(@Serializable val amount: MilliSatoshi, val source: LiquidityEvents.Source) : Disabled() } - sealed class ChannelsInitializing : PaymentRejected() { + sealed class ChannelFundingInProgress : PaymentRejected() { + @Serializable + data class V0(@Serializable val amount: MilliSatoshi, val source: LiquidityEvents.Source) : ChannelFundingInProgress() + } + + 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) : ChannelsInitializing() + data class V0(@Serializable val amount: MilliSatoshi, val source: LiquidityEvents.Source) : GenericError() } } sealed class WatchTowerOutcome: NotificationData() { sealed class Unknown : WatchTowerOutcome() { @Serializable - object V0: Unknown() + data object V0: Unknown() } sealed class Nominal : WatchTowerOutcome() { @Serializable @@ -101,37 +99,26 @@ internal sealed class NotificationData { } companion object { - fun deserialize(typeVersion: NotificationTypeVersion, blob: ByteArray): NotificationData? = try { - DbTypesHelper.decodeBlob(blob) { json, format -> - when (typeVersion) { - NotificationTypeVersion.PAYMENT_REJECTED_OVER_ABSOLUTE_V0 -> format.decodeFromString(json) - NotificationTypeVersion.PAYMENT_REJECTED_OVER_RELATIVE_V0 -> format.decodeFromString(json) - NotificationTypeVersion.PAYMENT_REJECTED_DISABLED_V0 -> format.decodeFromString(json) - NotificationTypeVersion.PAYMENT_REJECTED_CHANNELS_INIT_V0 -> format.decodeFromString(json) - NotificationTypeVersion.WATCH_TOWER_NOMINAL_V0 -> format.decodeFromString(json) - NotificationTypeVersion.WATCH_TOWER_UNKNOWN_V0 -> format.decodeFromString(json) - NotificationTypeVersion.WATCH_TOWER_REVOKED_FOUND_V0 -> format.decodeFromString(json) - - // obsolete types - NotificationTypeVersion.PAYMENT_REJECTED_BY_USER_V0, NotificationTypeVersion.PAYMENT_REJECTED_TOO_EXPENSIVE_V0 -> { - throw UnsupportedOperationException() - } - } - } + fun decode(blob: ByteArray): NotificationData? = try { + DbTypesHelper.decodeBlob(blob) { json, format -> format.decodeFromString(json) } } 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.ChannelFundingInProgress -> PaymentRejected.ChannelFundingInProgress.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 + } } } - -internal fun Notification.mapToDb(): Pair = when (this) { - is Notification.OverAbsoluteFee -> NotificationTypeVersion.PAYMENT_REJECTED_OVER_ABSOLUTE_V0 to Json.encodeToString(NotificationData.PaymentRejected.OverAbsoluteFee.V0(amount, source, fee, maxAbsoluteFee)).toByteArray(Charsets.UTF_8) - is Notification.OverRelativeFee -> NotificationTypeVersion.PAYMENT_REJECTED_OVER_RELATIVE_V0 to Json.encodeToString(NotificationData.PaymentRejected.OverRelativeFee.V0(amount, source, fee, maxRelativeFeeBasisPoints)).toByteArray(Charsets.UTF_8) - is Notification.FeePolicyDisabled -> NotificationTypeVersion.PAYMENT_REJECTED_DISABLED_V0 to Json.encodeToString(NotificationData.PaymentRejected.Disabled.V0(amount, source)).toByteArray(Charsets.UTF_8) - is Notification.ChannelsInitializing -> NotificationTypeVersion.PAYMENT_REJECTED_CHANNELS_INIT_V0 to Json.encodeToString(NotificationData.PaymentRejected.ChannelsInitializing.V0(amount, source)).toByteArray(Charsets.UTF_8) - is WatchTowerOutcome.Nominal -> NotificationTypeVersion.WATCH_TOWER_NOMINAL_V0 to Json.encodeToString(NotificationData.WatchTowerOutcome.Nominal.V0(channelsWatchedCount)).toByteArray(Charsets.UTF_8) - is WatchTowerOutcome.RevokedFound -> NotificationTypeVersion.WATCH_TOWER_REVOKED_FOUND_V0 to Json.encodeToString(NotificationData.WatchTowerOutcome.RevokedFound.V0(channels)).toByteArray(Charsets.UTF_8) - is WatchTowerOutcome.Unknown -> NotificationTypeVersion.WATCH_TOWER_UNKNOWN_V0 to Json.encodeToString(NotificationData.WatchTowerOutcome.Unknown.V0).toByteArray(Charsets.UTF_8) -} \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/NotificationsQueries.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/NotificationsQueries.kt index c82fab40c..260c55e9b 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/NotificationsQueries.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/NotificationsQueries.kt @@ -22,7 +22,9 @@ 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.AppDatabase +import fr.acinq.phoenix.db.notifications.NotificationData.Companion.encodeAsDb import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow @@ -33,16 +35,25 @@ internal class NotificationsQueries(val database: AppDatabase) { fun get(id: UUID): Notification? { return queries.get(id.toString()).executeAsOneOrNull()?.let { row -> - mapToNotification(row.id, row.type_version, row.data_json, row.created_at, row.read_at) + mapToNotification(row.id, row.data_json, row.created_at, row.read_at) } } fun save(notification: Notification) { - val (typeVersion, blob) = notification.mapToDb() queries.insert( id = notification.id.toString(), - type_version = typeVersion, - data_json = blob, + 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.ChannelFundingInProgress -> "PAYMENT_REJECTED_CHANNEL_FUNDING_IN_PROGRESS" + 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() ) } @@ -68,7 +79,7 @@ internal class NotificationsQueries(val database: AppDatabase) { return queries.listUnread().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.type_version, row.data_json, row.max ?: 0, null) + val notif = mapToNotification(row.id, row.data_json, row.max ?: 0, null) if (notif != null) { ids to notif } else { @@ -101,53 +112,58 @@ internal class NotificationsQueries(val database: AppDatabase) { /** Map columns to a [Notification] object. If the [data_json] column is unreadable, return null. */ internal fun mapToNotification( id: String, - type_version: NotificationTypeVersion, data_json: ByteArray, created_at: Long, read_at: Long?, ): Notification? { - return when (val data = NotificationData.deserialize(type_version, 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.ChannelsInitializing.V0 -> { - Notification.ChannelsInitializing( - id = UUID.fromString(id), - createdAt = created_at, - readAt = read_at, - amount = data.amount, - source = data.source, - ) - } - is NotificationData.WatchTowerOutcome -> null + 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.ChannelFundingInProgress.V0 -> Notification.ChannelFundingInProgress( + 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/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/DbTypesHelper.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/DbTypesHelper.kt index 98730f1bf..25f5c4a61 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/DbTypesHelper.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/DbTypesHelper.kt @@ -30,6 +30,7 @@ object DbTypesHelper { val module = SerializersModule { polymorphic(IncomingReceivedWithData.Part::class) { subclass(IncomingReceivedWithData.Part.Htlc.V0::class) + subclass(IncomingReceivedWithData.Part.Htlc.V1::class) @Suppress("DEPRECATION") subclass(IncomingReceivedWithData.Part.NewChannel.V0::class) subclass(IncomingReceivedWithData.Part.NewChannel.V1::class) diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/InboundLiquidityLeaseType.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/InboundLiquidityLeaseType.kt deleted file mode 100644 index c45249b8b..000000000 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/InboundLiquidityLeaseType.kt +++ /dev/null @@ -1,104 +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.payments - -import fr.acinq.bitcoin.ByteVector -import fr.acinq.bitcoin.ByteVector64 -import fr.acinq.bitcoin.Satoshi -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment -import fr.acinq.lightning.wire.LiquidityAds -import fr.acinq.phoenix.db.serializers.v1.ByteVector32Serializer -import fr.acinq.phoenix.db.serializers.v1.ByteVector64Serializer -import fr.acinq.phoenix.db.serializers.v1.ByteVectorSerializer -import fr.acinq.phoenix.db.serializers.v1.MilliSatoshiSerializer -import fr.acinq.phoenix.db.serializers.v1.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.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json - -enum class InboundLiquidityLeaseTypeVersion { - LEASE_V0, -} - -sealed class InboundLiquidityLeaseData { - - @Serializable - data class V0( - 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 - ) : InboundLiquidityLeaseData() - - companion object { - /** Deserializes a json-encoded blob containing data for an [LiquidityAds.Lease] object. */ - fun deserialize( - typeVersion: InboundLiquidityLeaseTypeVersion, - blob: ByteArray, - ): LiquidityAds.Lease = DbTypesHelper.decodeBlob(blob) { json, format -> - when (typeVersion) { - InboundLiquidityLeaseTypeVersion.LEASE_V0 -> format.decodeFromString(json).let { - LiquidityAds.Lease( - amount = it.amount, - fees = LiquidityAds.LeaseFees(miningFee = it.miningFees, serviceFee = it.serviceFee), - sellerSig = it.sellerSig, - witness = LiquidityAds.LeaseWitness( - fundingScript = it.witnessFundingScript, - leaseDuration = it.witnessLeaseDuration, - leaseEnd = it.witnessLeaseEnd, - maxRelayFeeProportional = it.witnessMaxRelayFeeProportional, - maxRelayFeeBase = it.witnessMaxRelayFeeBase, - ) - ) - } - } - } - } -} - -fun InboundLiquidityOutgoingPayment.mapLeaseToDb() = InboundLiquidityLeaseTypeVersion.LEASE_V0 to - InboundLiquidityLeaseData.V0( - amount = lease.amount, - miningFees = lease.fees.miningFee, - serviceFee = lease.fees.serviceFee, - sellerSig = lease.sellerSig, - witnessFundingScript = lease.witness.fundingScript, - witnessLeaseDuration = lease.witness.leaseDuration, - witnessLeaseEnd = lease.witness.leaseEnd, - witnessMaxRelayFeeProportional = lease.witness.maxRelayFeeProportional, - witnessMaxRelayFeeBase = lease.witness.maxRelayFeeBase, - ).let { - Json.encodeToString(it).toByteArray(Charsets.UTF_8) - } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/InboundLiquidityQueries.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/InboundLiquidityQueries.kt index 869e8fe09..618376b5f 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/InboundLiquidityQueries.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/InboundLiquidityQueries.kt @@ -21,23 +21,34 @@ import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment 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.data.WalletPaymentId import fr.acinq.phoenix.db.PaymentsDatabase import fr.acinq.phoenix.db.didSaveWalletPayment +import fr.acinq.phoenix.db.payments.liquidityads.PurchaseData +import fr.acinq.phoenix.db.payments.liquidityads.PurchaseData.Companion.encodeAsDb class InboundLiquidityQueries(val database: PaymentsDatabase) { private val queries = database.inboundLiquidityOutgoingQueries fun add(payment: InboundLiquidityOutgoingPayment) { database.transaction { - val (leaseType, leaseData) = payment.mapLeaseToDb() queries.insert( id = payment.id.toString(), mining_fees_sat = payment.miningFees.sat, channel_id = payment.channelId.toByteArray(), tx_id = payment.txId.value.toByteArray(), - lease_type = leaseType, - lease_blob = leaseData, + lease_type = when (payment.purchase) { + is LiquidityAds.Purchase.Standard -> "STANDARD" + is LiquidityAds.Purchase.WithFeeCredit -> "WITH_FEE_CREDIT" + }, + lease_blob = payment.purchase.encodeAsDb(), + payment_details_type = when (payment.purchase.paymentDetails) { + is LiquidityAds.PaymentDetails.FromChannelBalance -> "FROM_CHANNEL_BALANCE" + is LiquidityAds.PaymentDetails.FromFutureHtlc -> "FROM_FUTURE_HTLC" + is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> "FROM_FUTURE_HTLC_WITH_PREIMAGE" + is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> "FROM_CHANNEL_BALANCE_FOR_FUTURE_HTLC" + }, created_at = payment.createdAt, confirmed_at = payment.confirmedAt, locked_at = payment.lockedAt, @@ -50,6 +61,11 @@ class InboundLiquidityQueries(val database: PaymentsDatabase) { .executeAsOneOrNull() } + fun getByTxId(txId: TxId): InboundLiquidityOutgoingPayment? { + return queries.getByTxId(tx_id = txId.value.toByteArray(), mapper = Companion::mapPayment) + .executeAsOneOrNull() + } + fun setConfirmed(id: UUID, confirmedAt: Long) { database.transaction { queries.setConfirmed(confirmed_at = confirmedAt, id = id.toString()) @@ -70,7 +86,7 @@ class InboundLiquidityQueries(val database: PaymentsDatabase) { mining_fees_sat: Long, channel_id: ByteArray, tx_id: ByteArray, - lease_type: InboundLiquidityLeaseTypeVersion, + lease_type: String, lease_blob: ByteArray, created_at: Long, confirmed_at: Long?, @@ -81,7 +97,7 @@ class InboundLiquidityQueries(val database: PaymentsDatabase) { miningFees = mining_fees_sat.sat, channelId = channel_id.toByteVector32(), txId = TxId(tx_id), - lease = InboundLiquidityLeaseData.deserialize(lease_type, lease_blob), + purchase = PurchaseData.decodeAsCanonical(lease_type, lease_blob), createdAt = created_at, confirmedAt = confirmed_at, lockedAt = locked_at diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/IncomingReceivedWithType.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/IncomingReceivedWithType.kt index 6623e3df7..2ad4c3e14 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/IncomingReceivedWithType.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/IncomingReceivedWithType.kt @@ -34,6 +34,9 @@ import fr.acinq.phoenix.db.serializers.v1.ByteVector32Serializer import fr.acinq.phoenix.db.serializers.v1.MilliSatoshiSerializer import fr.acinq.phoenix.db.serializers.v1.UUIDSerializer import fr.acinq.lightning.utils.sat +import fr.acinq.phoenix.db.payments.liquidityads.FundingFeeData +import fr.acinq.phoenix.db.payments.liquidityads.FundingFeeData.Companion.asCanonical +import fr.acinq.phoenix.db.payments.liquidityads.FundingFeeData.Companion.asDb import fr.acinq.phoenix.db.serializers.v1.SatoshiSerializer import io.ktor.utils.io.charsets.* import io.ktor.utils.io.core.* @@ -80,12 +83,21 @@ sealed class IncomingReceivedWithData { @Serializable sealed class Part : IncomingReceivedWithData() { sealed class Htlc : Part() { + @Deprecated("Replaced by [Htlc.V1], which supports the liquidity ads funding fee") @Serializable data class V0( @Serializable val amount: MilliSatoshi, @Serializable val channelId: ByteVector32, val htlcId: Long ) : Htlc() + + @Serializable + data class V1( + val amountReceived: MilliSatoshi, + val channelId: ByteVector32, + val htlcId: Long, + val fundingFee: FundingFeeData?, + ) : Htlc() } sealed class NewChannel : Part() { @@ -131,6 +143,13 @@ sealed class IncomingReceivedWithData { @Serializable val lockedAt: Long?, ) : SpliceIn() } + + sealed class FeeCredit : Part() { + @Serializable + data class V0( + val amount: MilliSatoshi + ) : FeeCredit() + } } companion object { @@ -152,11 +171,11 @@ sealed class IncomingReceivedWithData { @Suppress("DEPRECATION") when (typeVersion) { IncomingReceivedWithTypeVersion.LIGHTNING_PAYMENT_V0 -> listOf( - IncomingPayment.ReceivedWith.LightningPayment(amount ?: 0.msat, ByteVector32.Zeroes, 0L) + IncomingPayment.ReceivedWith.LightningPayment(amount ?: 0.msat, ByteVector32.Zeroes, 0L, null) ) IncomingReceivedWithTypeVersion.NEW_CHANNEL_V0 -> listOf(format.decodeFromString(json).let { IncomingPayment.ReceivedWith.NewChannel( - amount = amount ?: 0.msat, + amountReceived = amount ?: 0.msat, serviceFee = it.fees, miningFee = 0.sat, channelId = it.channelId ?: ByteVector32.Zeroes, @@ -165,12 +184,13 @@ sealed class IncomingReceivedWithData { lockedAt = 0, ) }) - IncomingReceivedWithTypeVersion.MULTIPARTS_V0 -> DbTypesHelper.polymorphicFormat.decodeFromString(SetSerializer(PolymorphicSerializer(Part::class)), json).map { + IncomingReceivedWithTypeVersion.MULTIPARTS_V0 -> DbTypesHelper.polymorphicFormat.decodeFromString(SetSerializer(PolymorphicSerializer(Part::class)), json).mapNotNull { when (it) { - is Part.Htlc.V0 -> IncomingPayment.ReceivedWith.LightningPayment(it.amount, it.channelId, it.htlcId) + is Part.Htlc.V0 -> IncomingPayment.ReceivedWith.LightningPayment(it.amount, it.channelId, it.htlcId, null) + is Part.Htlc.V1 -> IncomingPayment.ReceivedWith.LightningPayment(it.amountReceived, it.channelId, it.htlcId, it.fundingFee?.asCanonical()) is Part.NewChannel.V0 -> if (originTypeVersion == IncomingOriginTypeVersion.SWAPIN_V0) { IncomingPayment.ReceivedWith.NewChannel( - amount = it.amount, + amountReceived = it.amount, serviceFee = it.fees, miningFee = 0.sat, channelId = it.channelId ?: ByteVector32.Zeroes, @@ -180,7 +200,7 @@ sealed class IncomingReceivedWithData { ) } else { IncomingPayment.ReceivedWith.NewChannel( - amount = it.amount - it.fees, + amountReceived = it.amount - it.fees, serviceFee = it.fees, miningFee = 0.sat, channelId = it.channelId ?: ByteVector32.Zeroes, @@ -191,12 +211,23 @@ sealed class IncomingReceivedWithData { } else -> null // does not apply, MULTIPARTS_V0 only uses V0 parts } - }.filterNotNull() // null elements are discarded! + } IncomingReceivedWithTypeVersion.MULTIPARTS_V1 -> DbTypesHelper.polymorphicFormat.decodeFromString(SetSerializer(PolymorphicSerializer(Part::class)), json).map { when (it) { - is Part.Htlc.V0 -> IncomingPayment.ReceivedWith.LightningPayment(it.amount, it.channelId, it.htlcId) + is Part.Htlc.V0 -> IncomingPayment.ReceivedWith.LightningPayment( + amountReceived = it.amount, + channelId = it.channelId, + htlcId = it.htlcId, + fundingFee = null + ) + is Part.Htlc.V1 -> IncomingPayment.ReceivedWith.LightningPayment( + amountReceived = it.amountReceived, + channelId = it.channelId, + htlcId = it.htlcId, + fundingFee = it.fundingFee?.asCanonical() + ) is Part.NewChannel.V0 -> IncomingPayment.ReceivedWith.NewChannel( - amount = it.amount, + amountReceived = it.amount, serviceFee = it.fees, miningFee = 0.sat, channelId = it.channelId ?: ByteVector32.Zeroes, @@ -205,7 +236,7 @@ sealed class IncomingReceivedWithData { lockedAt = 0, ) is Part.NewChannel.V1 -> IncomingPayment.ReceivedWith.NewChannel( - amount = it.amount, + amountReceived = it.amount, serviceFee = it.fees, miningFee = 0.sat, channelId = it.channelId ?: ByteVector32.Zeroes, @@ -214,7 +245,7 @@ sealed class IncomingReceivedWithData { lockedAt = 0, ) is Part.NewChannel.V2 -> IncomingPayment.ReceivedWith.NewChannel( - amount = it.amount, + amountReceived = it.amount, serviceFee = it.serviceFee, miningFee = it.miningFee, channelId = it.channelId, @@ -223,7 +254,7 @@ sealed class IncomingReceivedWithData { lockedAt = it.lockedAt, ) is Part.SpliceIn.V0 -> IncomingPayment.ReceivedWith.SpliceIn( - amount = it.amount, + amountReceived = it.amount, serviceFee = it.serviceFee, miningFee = it.miningFee, channelId = it.channelId, @@ -231,8 +262,11 @@ sealed class IncomingReceivedWithData { confirmedAt = it.confirmedAt, lockedAt = it.lockedAt, ) + is Part.FeeCredit.V0 -> IncomingPayment.ReceivedWith.AddedToFeeCredit( + amountReceived = it.amount + ) } - }.filterNotNull() // null elements are discarded! + } } } } @@ -241,9 +275,14 @@ sealed class IncomingReceivedWithData { /** Only serialize received_with into the [IncomingReceivedWithTypeVersion.MULTIPARTS_V1] type. */ fun List.mapToDb(): Pair? = map { when (it) { - is IncomingPayment.ReceivedWith.LightningPayment -> IncomingReceivedWithData.Part.Htlc.V0(it.amount, it.channelId, it.htlcId) + is IncomingPayment.ReceivedWith.LightningPayment -> IncomingReceivedWithData.Part.Htlc.V1( + amountReceived = it.amountReceived, + channelId = it.channelId, + htlcId = it.htlcId, + fundingFee = it.fundingFee?.asDb() + ) is IncomingPayment.ReceivedWith.NewChannel -> IncomingReceivedWithData.Part.NewChannel.V2( - amount = it.amount, + amount = it.amountReceived, serviceFee = it.serviceFee, miningFee = it.miningFee, channelId = it.channelId, @@ -252,7 +291,7 @@ fun List.mapToDb(): Pair IncomingReceivedWithData.Part.SpliceIn.V0( - amount = it.amount, + amount = it.amountReceived, serviceFee = it.serviceFee, miningFee = it.miningFee, channelId = it.channelId, @@ -260,6 +299,9 @@ fun List.mapToDb(): Pair IncomingReceivedWithData.Part.FeeCredit.V0( + amount = it.amountReceived + ) } }.takeIf { it.isNotEmpty() }?.toSet()?.let { IncomingReceivedWithTypeVersion.MULTIPARTS_V1 to DbTypesHelper.polymorphicFormat.encodeToString( diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/FundingFeeData.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/FundingFeeData.kt new file mode 100644 index 000000000..626853ed6 --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/FundingFeeData.kt @@ -0,0 +1,28 @@ +@file:UseSerializers( + MilliSatoshiSerializer::class, + TxIdSerializer::class, +) + +package fr.acinq.phoenix.db.payments.liquidityads + +import fr.acinq.bitcoin.TxId +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.phoenix.db.serializers.v1.MilliSatoshiSerializer +import fr.acinq.phoenix.db.serializers.v1.TxIdSerializer +import fr.acinq.lightning.wire.LiquidityAds +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers + +@Serializable +sealed class FundingFeeData { + + @Serializable + 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) + } + fun LiquidityAds.FundingFee.asDb(): FundingFeeData = V0(amount = amount, fundingTxId = fundingTxId) + } +} \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/LegacyLeaseData.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/LegacyLeaseData.kt new file mode 100644 index 000000000..f8602d565 --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/LegacyLeaseData.kt @@ -0,0 +1,62 @@ +/* + * 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.payments.liquidityads + +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +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.serializers.v1.* +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 +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/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/PaymentDetailsData.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/PaymentDetailsData.kt new file mode 100644 index 000000000..7431c9c9b --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/PaymentDetailsData.kt @@ -0,0 +1,67 @@ +/* + * 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.payments.liquidityads + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.phoenix.db.serializers.v1.ByteVector32Serializer +import fr.acinq.lightning.wire.LiquidityAds +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers + + +@Serializable +sealed class PaymentDetailsData { + sealed class ChannelBalance : PaymentDetailsData() { + @Serializable + data object V0 : ChannelBalance() + } + + sealed class FutureHtlc : PaymentDetailsData() { + @Serializable + data class V0(val paymentHashes: List) : FutureHtlc() + } + + sealed class FutureHtlcWithPreimage : PaymentDetailsData() { + @Serializable + data class V0(val preimages: List) : FutureHtlcWithPreimage() + } + + sealed class ChannelBalanceForFutureHtlc : PaymentDetailsData() { + @Serializable + 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) + } + + fun LiquidityAds.PaymentDetails.asDb(): PaymentDetailsData = when (this) { + is LiquidityAds.PaymentDetails.FromChannelBalance -> ChannelBalance.V0 + is LiquidityAds.PaymentDetails.FromFutureHtlc -> FutureHtlc.V0(this.paymentHashes) + is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> FutureHtlcWithPreimage.V0(this.preimages) + is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> ChannelBalanceForFutureHtlc.V0(this.paymentHashes) + } + } +} \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/PurchaseData.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/PurchaseData.kt new file mode 100644 index 000000000..336c950d5 --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/PurchaseData.kt @@ -0,0 +1,109 @@ +/* + * 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.payments.liquidityads + +import fr.acinq.bitcoin.Satoshi +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.phoenix.db.payments.liquidityads.PaymentDetailsData.Companion.asCanonical +import fr.acinq.phoenix.db.payments.liquidityads.PaymentDetailsData.Companion.asDb +import fr.acinq.phoenix.db.serializers.v1.SatoshiSerializer +import fr.acinq.phoenix.db.serializers.v1.MilliSatoshiSerializer +import fr.acinq.lightning.wire.LiquidityAds +import fr.acinq.phoenix.db.payments.DbTypesHelper +import io.ktor.utils.io.charsets.Charsets +import io.ktor.utils.io.core.toByteArray +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Serializable +sealed class PurchaseData { + sealed class Standard : PurchaseData() { + @Serializable + data class V0( + val amount: Satoshi, + val miningFees: Satoshi, + val serviceFee: Satoshi, + val paymentDetails: PaymentDetailsData, + ) : Standard() + } + sealed class WithFeeCredit : PurchaseData() { + @Serializable + 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() + ) + } + + private fun LiquidityAds.Purchase.asDb(): PurchaseData = when (val value = this) { + is LiquidityAds.Purchase.Standard -> Standard.V0( + amount = value.amount, + miningFees = value.fees.miningFee, + serviceFee = value.fees.serviceFee, + paymentDetails = value.paymentDetails.asDb() + ) + is LiquidityAds.Purchase.WithFeeCredit -> WithFeeCredit.V0( + amount = value.amount, value.fees.miningFee, + serviceFee = value.fees.serviceFee, + paymentDetails = value.paymentDetails.asDb(), + feeCreditUsed = value.feeCreditUsed + ) + } + + /** + * 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 = DbTypesHelper.decodeBlob(blob) { json, format -> + when (typeVersion) { + InboundLiquidityLeaseType.LEASE_V0.name -> format.decodeFromString(json).toLiquidityAdsPurchase() + else -> format.decodeFromString(json).asCanonical() + } + } + + fun LiquidityAds.Purchase.encodeAsDb(): ByteArray = Json.encodeToString(this.asDb()).toByteArray(Charsets.UTF_8) + } +} diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serializers/v1/TxIdSerializer.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serializers/v1/TxIdSerializer.kt new file mode 100644 index 000000000..5924f75a5 --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serializers/v1/TxIdSerializer.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.db.serializers.v1 + +import fr.acinq.bitcoin.TxId + +object TxIdSerializer : AbstractStringSerializer( + name = "TxId", + toString = TxId::toString, + fromString = ::TxId +) diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NodeParamsManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NodeParamsManager.kt index 72adb7050..90e46464d 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NodeParamsManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NodeParamsManager.kt @@ -92,30 +92,14 @@ class NodeParamsManager( val trampolineNodeId = PublicKey.fromHex("03933884aaf1d6b108397e5efe5c86bcf2d8ca8d2f700eda99db9214fc2712b134") val trampolineNodeUri = NodeUri(id = trampolineNodeId, "13.248.222.197", 9735) const val remoteSwapInXpub = "tpubDAmCFB21J9ExKBRPDcVxSvGs9jtcf8U1wWWbS1xTYmnUsuUHPCoFdCnEGxLE3THSWcQE48GHJnyz8XPbYUivBMbLSMBifFd3G9KmafkM9og" - val defaultLiquidityPolicy = LiquidityPolicy.Auto(maxAbsoluteFee = 5_000.sat, maxRelativeFeeBasisPoints = 50_00 /* 50% */, skipAbsoluteFeeCheck = false) - val payToOpenFeeBase = 100 + 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 + ) - fun liquidityLeaseRate(amount: Satoshi): LiquidityAds.LeaseRate { - // WARNING : THIS MUST BE KEPT IN SYNC WITH LSP OTHERWISE FUNDING REQUEST WILL BE REJECTED BY PHOENIX - val fundingWeight = if (amount <= 100_000.sat) { - 271 * 2 // 2-inputs (wpkh) / 0-change - } else if (amount <= 250_000.sat) { - 271 * 2 // 2-inputs (wpkh) / 0-change - } else if (amount <= 500_000.sat) { - 271 * 4 // 4-inputs (wpkh) / 0-change - } else if (amount <= 1_000_000.sat) { - 271 * 4 // 4-inputs (wpkh) / 0-change - } else { - 271 * 6 // 6-inputs (wpkh) / 0-change - } - return LiquidityAds.LeaseRate( - leaseDuration = 0, - fundingWeight = fundingWeight, - leaseFeeProportional = 100, // 1% - leaseFeeBase = 0.sat, - maxRelayFeeProportional = 100, - maxRelayFeeBase = 1_000.msat - ) - } + val payToOpenFeeBase = 100 } } \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NotificationsManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NotificationsManager.kt index e13f7b53b..57bab0343 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NotificationsManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NotificationsManager.kt @@ -99,7 +99,22 @@ class NotificationsManager( amount = event.amount, source = event.source, ) - is LiquidityEvents.Rejected.Reason.ChannelInitializing -> Notification.ChannelsInitializing( + is LiquidityEvents.Rejected.Reason.ChannelFundingInProgress -> Notification.ChannelFundingInProgress( + 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.NoMatchingFundingRate, + is LiquidityEvents.Rejected.Reason.TooManyParts -> Notification.GenericError( id = UUID.randomUUID(), createdAt = currentTimestampMillis(), readAt = null, diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PeerManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PeerManager.kt index 365b02a4a..3fdcda0e4 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PeerManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PeerManager.kt @@ -196,14 +196,12 @@ class PeerManager( client = electrumClient, watcher = electrumWatcher, db = databaseManager.databases.filterNotNull().first(), - trustedSwapInTxs = startupParams.trustedSwapInTxs, socketBuilder = null, scope = MainScope() ) _peer.value = peer launch { monitorNodeEvents(nodeParams) } - launch { updatePeerSwapInFeerate(peer) } // 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 @@ -250,14 +248,6 @@ class PeerManager( getPeer().nodeParams.liquidityPolicy.value = newPolicy } - /** Update the peer's swap-in feerate with values from mempool.space estimator. */ - private suspend fun updatePeerSwapInFeerate(peer: Peer) { - configurationManager.mempoolFeerate.filterNotNull().collect { feerate -> - logger.info { "using mempool.space feerate=$feerate" } - peer.swapInFeeratesFlow.value = FeeratePerKw(feerate.hour) - } - } - private suspend fun monitorNodeEvents(nodeParams: NodeParams) { nodeParams.nodeEvents.collect { event -> logger.debug { "collecting node_event=${event::class.simpleName}" } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/CsvWriter.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/CsvWriter.kt index 0debf1375..8c021bbb6 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/CsvWriter.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/CsvWriter.kt @@ -1,6 +1,7 @@ package fr.acinq.phoenix.utils import fr.acinq.lightning.db.* +import fr.acinq.lightning.payment.OfferPaymentMetadata import fr.acinq.phoenix.data.WalletPaymentInfo import kotlinx.datetime.Instant @@ -124,8 +125,8 @@ class CsvWriter { is IncomingPayment.Origin.OnChain -> { "Swap-in with inputs: ${origin.localInputs.map { it.txid.toString() } }" } - is IncomingPayment.Origin.Offer -> { - "Incoming offer ${origin.metadata.offerId}" + is IncomingPayment.Origin.Offer -> when (origin.metadata) { + is OfferPaymentMetadata.V1 -> "Incoming payment to your offer" } } is LightningOutgoingPayment -> when (val details = payment.details) { @@ -136,7 +137,7 @@ class CsvWriter { is SpliceOutgoingPayment -> "Outgoing splice to ${payment.address}" is ChannelCloseOutgoingPayment -> "Channel closing to ${payment.address}" is SpliceCpfpOutgoingPayment -> "Accelerate transactions with CPFP" - is InboundLiquidityOutgoingPayment -> "+${payment.lease.amount.sat} sat inbound liquidity" + is InboundLiquidityOutgoingPayment -> "+${payment.purchase.amount.sat} sat inbound liquidity" } row += ",${processField(details)}" } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentExtensions.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentExtensions.kt index 3c9d7eb37..23c63fdfb 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentExtensions.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentExtensions.kt @@ -17,6 +17,7 @@ package fr.acinq.phoenix.utils.extensions import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.db.LightningOutgoingPayment @@ -25,6 +26,9 @@ import fr.acinq.lightning.db.OutgoingPayment import fr.acinq.lightning.db.WalletPayment import fr.acinq.lightning.payment.Bolt12Invoice import fr.acinq.lightning.payment.OfferPaymentMetadata +import fr.acinq.lightning.utils.getValue +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sum import fr.acinq.lightning.wire.OfferTypes /** Standardized location for extending types from: fr.acinq.lightning. */ @@ -65,6 +69,19 @@ fun WalletPayment.state(): WalletPaymentState = when (this) { } } +/** + * Incoming payments may be received (in part or entirely) as a fee credit. This happens when an on-chain operation + * would be necessary to complete the payment, but the amount received is too low to pay for this operation just yet. + * The payment is then accepted, but the amount is accrued to a fee credit. + * + * This fee credit in the wallet is not part of the wallet's balance. It and can only be spent to pay future mining + * or service fees. It serves as a buffer that allows the user to keep accepting incoming payments seamlessly. + * + * Most of the time, this value is null (i.e., the amount received goes to the balance). + */ +val IncomingPayment.amountFeeCredit : MilliSatoshi? + get() = this.received?.receivedWith?.filterIsInstance()?.map { it.amountReceived }?.sum() + fun WalletPayment.paymentHashString(): String = when (this) { is OnChainOutgoingPayment -> throw NotImplementedError("no payment hash for on-chain outgoing") is LightningOutgoingPayment -> paymentHash.toString() diff --git a/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/InboundLiquidityOutgoing.sq b/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/InboundLiquidityOutgoing.sq index aa84ff6a5..565b84644 100644 --- a/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/InboundLiquidityOutgoing.sq +++ b/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/InboundLiquidityOutgoing.sq @@ -1,5 +1,3 @@ -import fr.acinq.phoenix.db.payments.InboundLiquidityLeaseTypeVersion; - -- Stores in a flat row payments standing for an inbound liquidity request (which are done through a splice). -- The lease data are stored in a complex column, as a json-encoded blob. See InboundLiquidityLeaseType file. CREATE TABLE IF NOT EXISTS inbound_liquidity_outgoing_payments ( @@ -7,8 +5,9 @@ CREATE TABLE IF NOT EXISTS inbound_liquidity_outgoing_payments ( mining_fees_sat INTEGER NOT NULL, channel_id BLOB NOT NULL, tx_id BLOB NOT NULL, - lease_type TEXT AS InboundLiquidityLeaseTypeVersion NOT NULL, + lease_type TEXT NOT NULL, lease_blob BLOB NOT NULL, + payment_details_type TEXT DEFAULT NULL, created_at INTEGER NOT NULL, confirmed_at INTEGER DEFAULT NULL, locked_at INTEGER DEFAULT NULL @@ -16,8 +15,8 @@ CREATE TABLE IF NOT EXISTS inbound_liquidity_outgoing_payments ( insert: INSERT INTO inbound_liquidity_outgoing_payments ( - id, mining_fees_sat, channel_id, tx_id, lease_type, lease_blob, created_at, confirmed_at, locked_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); + id, mining_fees_sat, channel_id, tx_id, lease_type, lease_blob, payment_details_type, created_at, confirmed_at, locked_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); setConfirmed: UPDATE inbound_liquidity_outgoing_payments SET confirmed_at=? WHERE id=?; @@ -30,5 +29,10 @@ SELECT id, mining_fees_sat, channel_id, tx_id, lease_type, lease_blob, created_a FROM inbound_liquidity_outgoing_payments WHERE id=?; +getByTxId: +SELECT id, mining_fees_sat, channel_id, tx_id, lease_type, lease_blob, created_at, confirmed_at, locked_at +FROM inbound_liquidity_outgoing_payments +WHERE tx_id=?; + delete: DELETE FROM inbound_liquidity_outgoing_payments WHERE id=?; diff --git a/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/migrations/9.sqm b/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/migrations/9.sqm new file mode 100644 index 000000000..0b8973f65 --- /dev/null +++ b/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/migrations/9.sqm @@ -0,0 +1,8 @@ +import fr.acinq.phoenix.db.payments.InboundLiquidityLeaseTypeVersion; + +-- Migration: v9 -> v10 +-- +-- Changes: +-- * Added a new column [payment_details_type] in table [inbound_liquidity_outgoing_payments] + +ALTER TABLE inbound_liquidity_outgoing_payments ADD COLUMN payment_details_type TEXT DEFAULT NULL; diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/IncomingPaymentDbTypeVersionTest.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/IncomingPaymentDbTypeVersionTest.kt index e20110e97..6fa68f9f0 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/IncomingPaymentDbTypeVersionTest.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/IncomingPaymentDbTypeVersionTest.kt @@ -62,7 +62,7 @@ class IncomingPaymentDbTypeVersionTest { @Test @Suppress("DEPRECATION") fun incoming_receivedwith_multipart_v0_lightning() { - val receivedWith = listOf(IncomingPayment.ReceivedWith.LightningPayment(100_000.msat, ByteVector32.One, 2L)) + val receivedWith = listOf(IncomingPayment.ReceivedWith.LightningPayment(100_000.msat, ByteVector32.One, 2L, null)) val deserialized = IncomingReceivedWithData.deserialize( IncomingReceivedWithTypeVersion.MULTIPARTS_V0, receivedWith.mapToDb()!!.second, @@ -74,7 +74,7 @@ class IncomingPaymentDbTypeVersionTest { @Test fun incoming_receivedwith_multipart_v1_lightning() { - val receivedWith = listOf(IncomingPayment.ReceivedWith.LightningPayment(100_000.msat, ByteVector32.One, 2L)) + val receivedWith = listOf(IncomingPayment.ReceivedWith.LightningPayment(100_000.msat, ByteVector32.One, 2L, null)) val deserialized = IncomingReceivedWithData.deserialize( IncomingReceivedWithTypeVersion.MULTIPARTS_V1, receivedWith.mapToDb()!!.second, @@ -146,7 +146,7 @@ class IncomingPaymentDbTypeVersionTest { IncomingOriginTypeVersion.INVOICE_V0 ).first() as IncomingPayment.ReceivedWith.LightningPayment - assertEquals(999_999.msat, deserialized.amount) + assertEquals(999_999.msat, deserialized.amountReceived) assertEquals(0.msat, deserialized.fees) assertEquals(ByteVector32.Zeroes, deserialized.channelId) assertEquals(0L, deserialized.htlcId) @@ -162,7 +162,7 @@ class IncomingPaymentDbTypeVersionTest { IncomingOriginTypeVersion.SWAPIN_V0 ) .first() as IncomingPayment.ReceivedWith.NewChannel - assertEquals(123_456.msat, deserialized.amount) + assertEquals(123_456.msat, deserialized.amountReceived) assertEquals(15_000.msat, deserialized.fees) assertEquals(channelId1, deserialized.channelId) } diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/SqlitePaymentsDatabaseTest.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/SqlitePaymentsDatabaseTest.kt index 676165fde..6e468fcc7 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/SqlitePaymentsDatabaseTest.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/SqlitePaymentsDatabaseTest.kt @@ -21,14 +21,11 @@ import fr.acinq.bitcoin.* import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* import fr.acinq.lightning.Lightning.randomBytes32 -import fr.acinq.lightning.channel.TooManyAcceptedHtlcs import fr.acinq.lightning.db.* import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.payment.FinalFailure -import fr.acinq.lightning.payment.OutgoingPaymentFailure -import fr.acinq.lightning.payment.PaymentRequest import fr.acinq.lightning.utils.* -import fr.acinq.lightning.wire.TemporaryNodeFailure +import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.phoenix.data.WalletPaymentFetchOptions import fr.acinq.phoenix.runTest import fr.acinq.phoenix.utils.migrations.LegacyChannelCloseHelper @@ -43,12 +40,12 @@ class SqlitePaymentsDatabaseTest { private val paymentHash1 = Crypto.sha256(preimage1).toByteVector32() private val origin1 = IncomingPayment.Origin.Invoice(createInvoice(preimage1)) private val channelId1 = randomBytes32() - private val receivedWith1 = listOf(IncomingPayment.ReceivedWith.LightningPayment(100_000.msat, channelId1, 1L)) - private val receivedWith3 = listOf(IncomingPayment.ReceivedWith.LightningPayment(150_000.msat, channelId1, 1L)) + private val receivedWith1 = listOf(IncomingPayment.ReceivedWith.LightningPayment(100_000.msat, channelId1, 1L, null)) + private val receivedWith3 = listOf(IncomingPayment.ReceivedWith.LightningPayment(150_000.msat, channelId1, 1L, fundingFee = LiquidityAds.FundingFee(2_000.msat, TxId(randomBytes32())))) private val preimage2 = randomBytes32() private val receivedWith2 = listOf( - IncomingPayment.ReceivedWith.NewChannel(amount = 1_995_000.msat, serviceFee = 5_000.msat, channelId = randomBytes32(), txId = TxId(randomBytes32()), miningFee = 100.sat, confirmedAt = 100, lockedAt = 200) + IncomingPayment.ReceivedWith.NewChannel(amountReceived = 1_995_000.msat, serviceFee = 5_000.msat, channelId = randomBytes32(), txId = TxId(randomBytes32()), miningFee = 100.sat, confirmedAt = 100, lockedAt = 200) ) val origin3 = IncomingPayment.Origin.SwapIn(address = "1PwLgmRdDjy5GAKWyp8eyAC4SFzWuboLLb") @@ -90,8 +87,8 @@ class SqlitePaymentsDatabaseTest { val origin = IncomingPayment.Origin.Invoice(createInvoice(preimage, 1_000_000_000.msat)) val channelId = randomBytes32() val txId = TxId(randomBytes32()) - val mppPart1 = IncomingPayment.ReceivedWith.NewChannel(amount = 600_000_000.msat, serviceFee = 5_000.msat, miningFee = 100.sat, channelId = channelId, txId = txId, confirmedAt = 100, lockedAt = 50) - val mppPart2 = IncomingPayment.ReceivedWith.NewChannel(amount = 400_000_000.msat, serviceFee = 5_000.msat, miningFee = 200.sat, channelId = channelId, txId = txId, confirmedAt = 115, lockedAt = 75) + val mppPart1 = IncomingPayment.ReceivedWith.NewChannel(amountReceived = 600_000_000.msat, serviceFee = 5_000.msat, miningFee = 100.sat, channelId = channelId, txId = txId, confirmedAt = 100, lockedAt = 50) + val mppPart2 = IncomingPayment.ReceivedWith.NewChannel(amountReceived = 400_000_000.msat, serviceFee = 5_000.msat, miningFee = 200.sat, channelId = channelId, txId = txId, confirmedAt = 115, lockedAt = 75) val receivedWith = listOf(mppPart1, mppPart2) db.addIncomingPayment(preimage, origin, 0) @@ -106,8 +103,8 @@ class SqlitePaymentsDatabaseTest { val origin = IncomingPayment.Origin.Invoice(createInvoice(preimage, 1_000_000_000.msat)) val channelId = randomBytes32() val txId = TxId(randomBytes32()) - val mppPart1 = IncomingPayment.ReceivedWith.NewChannel(amount = 500_000_000.msat, serviceFee = 5_000.msat, miningFee = 200.sat, channelId = channelId, txId = txId, confirmedAt = 100, lockedAt = 50) - val mppPart2 = IncomingPayment.ReceivedWith.NewChannel(amount = 500_000_000.msat, serviceFee = 5_000.msat, miningFee = 150.sat, channelId = channelId, txId = txId, confirmedAt = 115, lockedAt = 75) + val mppPart1 = IncomingPayment.ReceivedWith.NewChannel(amountReceived = 500_000_000.msat, serviceFee = 5_000.msat, miningFee = 200.sat, channelId = channelId, txId = txId, confirmedAt = 100, lockedAt = 50) + val mppPart2 = IncomingPayment.ReceivedWith.NewChannel(amountReceived = 500_000_000.msat, serviceFee = 5_000.msat, miningFee = 150.sat, channelId = channelId, txId = txId, confirmedAt = 115, lockedAt = 75) val receivedWith = listOf(mppPart1, mppPart2) db.addIncomingPayment(preimage, origin, 0) diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/cloud/CloudDataTest.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/cloud/CloudDataTest.kt index adcf64408..5ff812723 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/cloud/CloudDataTest.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/cloud/CloudDataTest.kt @@ -9,6 +9,7 @@ import fr.acinq.lightning.db.* import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.payment.FinalFailure import fr.acinq.lightning.utils.* +import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.phoenix.runTest import fr.acinq.secp256k1.Hex import kotlin.test.* @@ -86,10 +87,10 @@ class CloudDataTest { fun incoming__receivedWith_lightning() = runTest { val invoice = createBolt11Invoice(preimage, 250_000.msat) val receivedWith1 = IncomingPayment.ReceivedWith.LightningPayment( - amount = 100_000.msat, channelId = channelId, htlcId = 1L + amountReceived = 100_000.msat, channelId = channelId, htlcId = 1L, fundingFee = null ) val receivedWith2 = IncomingPayment.ReceivedWith.LightningPayment( - amount = 150_000.msat, channelId = channelId, htlcId = 1L + amountReceived = 150_000.msat, channelId = channelId, htlcId = 1L, fundingFee = LiquidityAds.FundingFee(amount = 1_000.msat, TxId(ByteVector32.Zeroes)) ) testRoundtrip( IncomingPayment( @@ -104,7 +105,7 @@ class CloudDataTest { fun incoming__receivedWith_newChannel() = runTest { val invoice = createBolt11Invoice(preimage, 10_000_000.msat) val receivedWith = IncomingPayment.ReceivedWith.NewChannel( - amount = 7_000_000.msat, miningFee = 2_000.sat, serviceFee = 1_000_000.msat, channelId = channelId, txId = TxId(randomBytes32()), confirmedAt = 500, lockedAt = 800 + amountReceived = 7_000_000.msat, miningFee = 2_000.sat, serviceFee = 1_000_000.msat, channelId = channelId, txId = TxId(randomBytes32()), confirmedAt = 500, lockedAt = 800 ) testRoundtrip( IncomingPayment( @@ -127,8 +128,8 @@ class CloudDataTest { val expectedChannelId = Hex.decode("e8a0e7ba91a485ed6857415cc0c60f77eda6cb1ebe1da841d42d7b4388cc2bcc").byteVector32() val expectedReceived = IncomingPayment.Received( receivedWith = listOf( - IncomingPayment.ReceivedWith.NewChannel(amount = 7_000_000.msat, miningFee = 0.sat, serviceFee = 3_000_000.msat, channelId = expectedChannelId, txId = TxId(ByteVector32.Zeroes), confirmedAt = 0, lockedAt = 0), - IncomingPayment.ReceivedWith.NewChannel(amount = 9_000_000.msat, miningFee = 0.sat, serviceFee = 6_000_000.msat, channelId = expectedChannelId, txId = TxId(ByteVector32.Zeroes), confirmedAt = 0, lockedAt = 0) + IncomingPayment.ReceivedWith.NewChannel(amountReceived = 7_000_000.msat, miningFee = 0.sat, serviceFee = 3_000_000.msat, channelId = expectedChannelId, txId = TxId(ByteVector32.Zeroes), confirmedAt = 0, lockedAt = 0), + IncomingPayment.ReceivedWith.NewChannel(amountReceived = 9_000_000.msat, miningFee = 0.sat, serviceFee = 6_000_000.msat, channelId = expectedChannelId, txId = TxId(ByteVector32.Zeroes), confirmedAt = 0, lockedAt = 0) ), receivedAt = 1658246347319 ) diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/CsvWriterTests.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/CsvWriterTests.kt index 5494afe67..e8361bd21 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/CsvWriterTests.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/CsvWriterTests.kt @@ -20,6 +20,7 @@ import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.currentTimestampSeconds import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.phoenix.TestConstants import fr.acinq.phoenix.data.ExchangeRate import fr.acinq.phoenix.data.FiatCurrency @@ -42,7 +43,7 @@ class CsvWriterTests { received = IncomingPayment.Received( receivedWith = listOf( IncomingPayment.ReceivedWith.NewChannel( - amount = 12_000_000.msat, + amountReceived = 12_000_000.msat, serviceFee = 3_000_000.msat, miningFee = 0.sat, channelId = randomBytes32(), @@ -78,9 +79,10 @@ class CsvWriterTests { received = IncomingPayment.Received( receivedWith = listOf( IncomingPayment.ReceivedWith.LightningPayment( - amount = 2_173_929.msat, + amountReceived = 2_173_929.msat, channelId = randomBytes32(), - htlcId = 0 + htlcId = 0, + fundingFee = LiquidityAds.FundingFee(2_000.msat, TxId(randomBytes32())) ) ), receivedAt = 1675270484965 @@ -92,7 +94,7 @@ class CsvWriterTests { userNotes = null ) - val expected = "2023-02-01T16:54:44.965Z,2173929,0,0.4999 USD,0.0000 USD,Incoming LN payment,Cafécito,\r\n" + val expected = "2023-02-01T16:54:44.965Z,2173929,-2000,0.4999 USD,-0.0004 USD,Incoming LN payment,Cafécito,\r\n" val actual = CsvWriter.makeRow( info = WalletPaymentInfo(payment, metadata, null, WalletPaymentFetchOptions.All), localizedDescription = "Cafécito", @@ -238,7 +240,7 @@ class CsvWriterTests { received = IncomingPayment.Received( receivedWith = listOf( IncomingPayment.ReceivedWith.NewChannel( - amount = 12_000_000.msat, + amountReceived = 12_000_000.msat, serviceFee = 2_931_000.msat, miningFee = 69.sat, channelId = randomBytes32(), diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/ParserTest.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/ParserTest.kt index 4c177c5d8..7cd194de2 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/ParserTest.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/ParserTest.kt @@ -129,30 +129,33 @@ class ParserTest { @Test fun parse_bitcoin_uri_with_lightning_invoice() { - listOf>>( - // valid lightning invoice - "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?foo=bar&lightning=lntb15u1p05vazrpp5apz75ghtq3ynmc5qm98tsgucmsav44fyffpguhzdep2kcgkfme4sdq4xysyymr0vd4kzcmrd9hx7cqp2xqrrss9qy9qsqsp5v4hqr48qe0u7al6lxwdpmp3w6k7evjdavm0lh7arpv3qaf038s5st2d8k8vvmxyav2wkfym9jp4mk64srmswgh7l6sqtq7l4xl3nknf8snltamvpw5p3yl9nxg0ax9k0698rr94qx6unrv8yhccmh4z9ghcq77hxps" to Either.Right( - BitcoinUri( - chain = Chain.Mainnet, - address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", - script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6"), - paymentRequest = Bolt11Invoice.read("lntb15u1p05vazrpp5apz75ghtq3ynmc5qm98tsgucmsav44fyffpguhzdep2kcgkfme4sdq4xysyymr0vd4kzcmrd9hx7cqp2xqrrss9qy9qsqsp5v4hqr48qe0u7al6lxwdpmp3w6k7evjdavm0lh7arpv3qaf038s5st2d8k8vvmxyav2wkfym9jp4mk64srmswgh7l6sqtq7l4xl3nknf8snltamvpw5p3yl9nxg0ax9k0698rr94qx6unrv8yhccmh4z9ghcq77hxps") - .get(), - ignoredParams = ParametersBuilder().apply { set("foo", "bar") }.build() - ) - ), - // invalid lightning invoice - "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?lightning=lntb15u1p05vazrpp" to Either.Right( - BitcoinUri(chain = Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6")) - ), - // empty lightning invoice - "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?lightning=" to Either.Right( - BitcoinUri(chain = Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6")) + assertEquals( + expected = BitcoinUri( + chain = Chain.Mainnet, + address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6"), + paymentRequest = Bolt11Invoice.read("lnbc10u1pn08ld4pp5x7vvjs56tsj65c5xxe43xtxc22n6umuc89hwjndkwkazduzqhsesdpcge6kuerfdenjqspsxvcxxd33vseryefqdahzqum5v93kketj9ehx2amncqzzsxqrrs0sp5g2gjnwnsmy7xfprvjsuppymeqvr0zm3tksmqtg2sqdyqmxaxasxq9qxpqysgq3rj5d9vx7vsmfe8pxzqx7jzes77sta32yp9rqx78dkh4fn8lg8mk9kzh29255qgamcdddf30pp6hptk0u432sg39h3rjxru0ec5edycpxpqmg3").get(), + ignoredParams = ParametersBuilder().apply { set("foo", "bar") }.build() ), - ).forEach { (address, expected) -> - val uri = Parser.parseBip21Uri(Chain.Mainnet, address) - assertEquals(expected, uri) - } + actual = Parser.parseBip21Uri( + chain = Chain.Mainnet, + input = "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?foo=bar&lightning=lnbc10u1pn08ld4pp5x7vvjs56tsj65c5xxe43xtxc22n6umuc89hwjndkwkazduzqhsesdpcge6kuerfdenjqspsxvcxxd33vseryefqdahzqum5v93kketj9ehx2amncqzzsxqrrs0sp5g2gjnwnsmy7xfprvjsuppymeqvr0zm3tksmqtg2sqdyqmxaxasxq9qxpqysgq3rj5d9vx7vsmfe8pxzqx7jzes77sta32yp9rqx78dkh4fn8lg8mk9kzh29255qgamcdddf30pp6hptk0u432sg39h3rjxru0ec5edycpxpqmg3" + ).right + ) + assertEquals( + expected = BitcoinUri(chain = Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6"), paymentRequest = null), + actual = Parser.parseBip21Uri( + chain = Chain.Mainnet, + input = "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?lightning=lntb15u1p05vazrpp" + ).right + ) + assertEquals( + expected = BitcoinUri(chain = Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6"), paymentRequest = null), + actual = Parser.parseBip21Uri( + chain = Chain.Mainnet, + input = "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?lightning=" + ).right + ) } @Test diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/iosDbFactory.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/iosDbFactory.kt index 12ec20df2..336fae9fd 100644 --- a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/iosDbFactory.kt +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/iosDbFactory.kt @@ -30,7 +30,11 @@ actual fun createChannelsDbDriver( nodeIdHash: String ): SqlDriver { val schema = ChannelsDatabase.Schema - val name = "channels-${chain.name.lowercase()}-$nodeIdHash.sqlite" + val chainName = when (chain) { + is Chain.Testnet3 -> "testnet" + else -> chain.name.lowercase() + } + val name = "channels-$chainName-$nodeIdHash.sqlite" // The foreign_keys constraint needs to be set via the DatabaseConfiguration: // https://github.com/cashapp/sqldelight/issues/1356 @@ -59,7 +63,11 @@ actual fun createPaymentsDbDriver( nodeIdHash: String ): SqlDriver { val schema = PaymentsDatabase.Schema - val name = "payments-${chain.name.lowercase()}-$nodeIdHash.sqlite" + val chainName = when (chain) { + is Chain.Testnet3 -> "testnet" + else -> chain.name.lowercase() + } + val name = "payments-$chainName-$nodeIdHash.sqlite" val dbDir = getDatabaseFilesDirectoryPath(ctx) val configuration = DatabaseConfiguration( From 8111e81513f970f4556b03748620cdf135bb77a4 Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Wed, 25 Sep 2024 16:32:47 +0200 Subject: [PATCH 7/7] (android) Update the payment screens for liquidity purchases With the new on-the-fly mechanism, the automated liquidity operation necesssary to receive some payments are stored as specific payments. That is, the incoming payments related to this operation do not have a fee, instead there's a payment row in the payments history specific to the liquidity purchase. The operation also has its own payment details page just like the (already existing) manual liquidity purchase. The splash screen file has been split into several files, one for each payment types. This makes the code more flexible. The liquidity policy objects set in the channels management screen have been updated to match the NodeParamsManager value. This commit is a WIP, especially for wording and localisation. --- .../android/components/BottomSheetDialog.kt | 70 ++ .../phoenix/android/components/Dialogs.kt | 4 +- .../android/components/SplashLayout.kt | 22 +- .../details/PaymentDetailsSplashView.kt | 880 ------------------ .../details/PaymentDetailsTechnicalView.kt | 74 +- .../payments/details/PaymentDetailsView.kt | 1 + .../android/payments/details/PaymentLine.kt | 2 +- .../details/splash/PaymentSplashStatus.kt | 388 ++++++++ .../details/splash/PaymentSplashView.kt | 233 +++++ .../details/splash/SplashChannelClose.kt | 86 ++ .../payments/details/splash/SplashIncoming.kt | 141 +++ .../details/splash/SplashLightningOut.kt | 237 +++++ .../details/splash/SplashLiquidityPurchase.kt | 267 ++++++ .../details/splash/SplashSpliceOut.kt | 73 ++ .../details/splash/SplashSpliceOutCpfp.kt | 73 ++ .../liquidity/RequestLiquidityView.kt | 14 +- .../liquidity/RequestLiquidityViewModel.kt | 21 +- .../android/payments/offer/SendOfferView.kt | 2 +- .../phoenix/android/services/NodeService.kt | 25 +- .../android/settings/NotificationsView.kt | 6 +- .../fees/AdvancedIncomingFeePolicy.kt | 11 +- .../settings/fees/LiquidityPolicyView.kt | 11 +- .../settings/walletinfo/SwapInWalletInfo.kt | 4 +- .../android/utils/LegacyMigrationHelper.kt | 15 +- .../android/utils/SystemNotificationHelper.kt | 4 +- .../utils/datastore/UserPrefsRepository.kt | 9 +- .../acinq/phoenix/android/utils/extensions.kt | 49 +- .../res/values-b+es+419/important_strings.xml | 1 - .../src/main/res/values-b+es+419/strings.xml | 2 +- .../main/res/values-cs/important_strings.xml | 1 - .../main/res/values-de/important_strings.xml | 1 - .../src/main/res/values-de/strings.xml | 3 +- .../main/res/values-es/important_strings.xml | 1 - .../main/res/values-fr/important_strings.xml | 1 - .../src/main/res/values-fr/strings.xml | 1 - .../res/values-pt-rBR/important_strings.xml | 1 - .../main/res/values-sk/important_strings.xml | 1 - .../src/main/res/values-sk/strings.xml | 1 - .../main/res/values-sw/important_strings.xml | 1 - .../src/main/res/values-sw/strings.xml | 1 - .../main/res/values-vi/important_strings.xml | 1 - .../src/main/res/values-vi/strings.xml | 1 - .../src/main/res/values/important_strings.xml | 6 +- .../src/main/res/values/strings.xml | 7 +- 44 files changed, 1771 insertions(+), 982 deletions(-) create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/BottomSheetDialog.kt delete mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsSplashView.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/PaymentSplashStatus.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/PaymentSplashView.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashChannelClose.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashIncoming.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashLightningOut.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashLiquidityPurchase.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashSpliceOut.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashSpliceOutCpfp.kt diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/BottomSheetDialog.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/BottomSheetDialog.kt new file mode 100644 index 000000000..e142c3d35 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/BottomSheetDialog.kt @@ -0,0 +1,70 @@ +/* + * 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.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BottomSheetDialog( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + skipPartiallyExpanded: Boolean = true, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + scrimAlpha: Float = 0.2f, + internalPadding: PaddingValues = PaddingValues(top = 0.dp, start = 20.dp, end = 20.dp, bottom = 64.dp), + content: @Composable ColumnScope.() -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded) + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = { + // executed when user click outside the sheet, and after sheet has been hidden thru state. + onDismiss() + }, + modifier = modifier, + containerColor = MaterialTheme.colors.surface, + contentColor = MaterialTheme.colors.onSurface, + scrimColor = MaterialTheme.colors.onBackground.copy(alpha = scrimAlpha), + ) { + Column( + horizontalAlignment = horizontalAlignment, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(internalPadding) + ) { + content() + } + } +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Dialogs.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Dialogs.kt index c8925eb76..23166c70b 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Dialogs.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Dialogs.kt @@ -179,7 +179,7 @@ fun RowScope.IconPopup( popupLink: Pair? = null, spaceLeft: Dp? = 8.dp, spaceRight: Dp? = null, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { var showPopup by remember { mutableStateOf(false) } spaceLeft?.let { Spacer(Modifier.requiredWidth(it)) } @@ -191,7 +191,7 @@ fun RowScope.IconPopup( padding = PaddingValues(iconPadding), modifier = modifier.requiredSize(iconSize), interactionSource = interactionSource, - onClick = { showPopup = true } + onClick = { showPopup = true }, ) if (showPopup) { PopupDialog(onDismiss = { showPopup = false }, message = popupMessage, button = popupLink?.let { (text, link) -> diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/SplashLayout.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/SplashLayout.kt index 7afac378b..7a47b94b0 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/SplashLayout.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/SplashLayout.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.ButtonDefaults import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text @@ -34,7 +33,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -127,27 +125,29 @@ fun SplashLabelRow( ) { Row { Row( - modifier = Modifier.weight(1f).alignByBaseline(), + modifier = Modifier + .weight(1f) + .alignByBaseline(), horizontalArrangement = Arrangement.End ) { + Spacer(modifier = Modifier.weight(1f)) + if (helpMessage != null) { + IconPopup(modifier = Modifier.offset(y = (-3).dp), popupMessage = helpMessage, popupLink = helpLink, spaceLeft = 0.dp, spaceRight = 5.dp) + } Text( text = label.uppercase(), - style = MaterialTheme.typography.subtitle1.copy(fontSize = 12.sp, textAlign = TextAlign.End), + style = MaterialTheme.typography.subtitle1.copy(fontSize = 12.sp), maxLines = 2, overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) ) - if (helpMessage != null) { - IconPopup(modifier = Modifier.offset(y = (-3).dp), popupMessage = helpMessage, popupLink = helpLink, spaceLeft = 4.dp, spaceRight = 0.dp) - } if (icon != null) { - Spacer(modifier = Modifier.width(4.dp)) + Spacer(modifier = Modifier.width(3.dp)) Image( painter = painterResource(id = icon), colorFilter = ColorFilter.tint(iconTint), contentDescription = null, modifier = Modifier - .size(ButtonDefaults.IconSize) + .size(17.dp) .offset(y = (-2).dp) ) } @@ -175,7 +175,7 @@ fun SplashClickableContent( .offset(x = (-8).dp), shape = RoundedCornerShape(12.dp) ) { - Column(modifier = Modifier.padding(8.dp)) { + Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) { content() } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsSplashView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsSplashView.kt deleted file mode 100644 index f59485151..000000000 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsSplashView.kt +++ /dev/null @@ -1,880 +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.payments.details - -import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi -import androidx.compose.animation.graphics.res.animatedVectorResource -import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter -import androidx.compose.animation.graphics.vector.AnimatedImageVector -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.PublicKey -import fr.acinq.bitcoin.TxId -import fr.acinq.bitcoin.utils.Either -import fr.acinq.lightning.blockchain.electrum.ElectrumConnectionStatus -import fr.acinq.lightning.db.* -import fr.acinq.lightning.payment.FinalFailure -import fr.acinq.lightning.payment.OutgoingPaymentFailure -import fr.acinq.lightning.utils.msat -import fr.acinq.lightning.utils.sum -import fr.acinq.lightning.wire.LiquidityAds -import fr.acinq.phoenix.android.LocalBitcoinUnit -import fr.acinq.phoenix.android.R -import fr.acinq.phoenix.android.business -import fr.acinq.phoenix.android.components.* -import fr.acinq.phoenix.android.components.contact.ContactCompactView -import fr.acinq.phoenix.android.components.contact.ContactOrOfferView -import fr.acinq.phoenix.android.components.contact.OfferContactState -import fr.acinq.phoenix.android.payments.cpfp.CpfpView -import fr.acinq.phoenix.android.utils.* -import fr.acinq.phoenix.android.utils.Converter.toPrettyString -import fr.acinq.phoenix.android.utils.Converter.toRelativeDateString -import fr.acinq.phoenix.data.LnurlPayMetadata -import fr.acinq.phoenix.data.WalletPaymentId -import fr.acinq.phoenix.data.WalletPaymentInfo -import fr.acinq.phoenix.data.lnurl.LnurlPay -import fr.acinq.phoenix.utils.extensions.WalletPaymentState -import fr.acinq.phoenix.utils.extensions.minDepthForFunding -import fr.acinq.phoenix.utils.extensions.incomingOfferMetadata -import fr.acinq.phoenix.utils.extensions.outgoingInvoiceRequest -import fr.acinq.phoenix.utils.extensions.state -import io.ktor.http.Url -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import javax.crypto.Cipher -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec - -@Composable -fun PaymentDetailsSplashView( - onBackClick: () -> Unit, - data: WalletPaymentInfo, - onDetailsClick: (WalletPaymentId) -> Unit, - onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit, - fromEvent: Boolean, -) { - val payment = data.payment - SplashLayout( - header = { DefaultScreenHeader(onBackClick = onBackClick) }, - topContent = { PaymentStatus(data.payment, fromEvent, onCpfpSuccess = onBackClick) } - ) { - AmountView( - amount = when (payment) { - is InboundLiquidityOutgoingPayment -> payment.amount - is OutgoingPayment -> payment.amount - payment.fees - is IncomingPayment -> payment.amount - }, - amountTextStyle = MaterialTheme.typography.body1.copy(fontSize = 30.sp), - separatorSpace = 4.dp, - prefix = stringResource(id = if (payment is OutgoingPayment) R.string.paymentline_prefix_sent else R.string.paymentline_prefix_received) - ) - - Spacer(modifier = Modifier.height(36.dp)) - PrimarySeparator( - height = 6.dp, - color = when (payment.state()) { - WalletPaymentState.Failure -> negativeColor - WalletPaymentState.SuccessOffChain, WalletPaymentState.SuccessOnChain -> positiveColor - else -> mutedBgColor - } - ) - Spacer(modifier = Modifier.height(36.dp)) - - if (data.payment is LightningOutgoingPayment && data.metadata.lnurl != null) { - LnurlPayInfoView(data.payment as LightningOutgoingPayment, data.metadata.lnurl!!) - } - - payment.incomingOfferMetadata()?.let { meta -> - meta.payerNote?.takeIf { it.isNotBlank() }?.let { - OfferPayerNote(payerNote = it) - Spacer(modifier = Modifier.height(8.dp)) - } - OfferSentBy(payerPubkey = meta.payerKey, !meta.payerNote.isNullOrBlank()) - } - - payment.outgoingInvoiceRequest()?.payerNote?.takeIf { it.isNotBlank() }?.let { - OfferPayerNote(payerNote = it) - } - - PaymentDescriptionView(data = data, onMetadataDescriptionUpdate = onMetadataDescriptionUpdate) - PaymentDestinationView(data = data) - PaymentFeeView(payment = payment) - if (payment is InboundLiquidityOutgoingPayment) { - InboundLiquidityLeaseDetails(lease = payment.lease) - } - - if (payment is LightningOutgoingPayment) { - (payment.status as? LightningOutgoingPayment.Status.Completed.Failed)?.let { status -> - PaymentErrorView(status = status, failedParts = payment.parts.map { it.status }.filterIsInstance()) - } - } - - Spacer(modifier = Modifier.height(48.dp)) - BorderButton( - text = stringResource(id = R.string.paymentdetails_details_button), - borderColor = borderColor, - textStyle = MaterialTheme.typography.caption, - icon = R.drawable.ic_tool, - iconTint = MaterialTheme.typography.caption.color, - onClick = { onDetailsClick(data.id()) }, - ) - } -} - -@Composable -private fun PaymentStatus( - payment: WalletPayment, - fromEvent: Boolean, - onCpfpSuccess: () -> Unit, -) { - val peerManager = business.peerManager - when (payment) { - is LightningOutgoingPayment -> when (payment.status) { - is LightningOutgoingPayment.Status.Pending -> PaymentStatusIcon( - message = { Text(text = stringResource(id = R.string.paymentdetails_status_sent_pending)) }, - imageResId = R.drawable.ic_payment_details_pending_static, - isAnimated = false, - color = mutedTextColor - ) - is LightningOutgoingPayment.Status.Completed.Failed -> PaymentStatusIcon( - message = { Text(text = annotatedStringResource(id = R.string.paymentdetails_status_sent_failed), textAlign = TextAlign.Center) }, - imageResId = R.drawable.ic_payment_details_failure_static, - isAnimated = false, - color = negativeColor - ) - is LightningOutgoingPayment.Status.Completed.Succeeded -> PaymentStatusIcon( - message = { - Text(text = annotatedStringResource(id = R.string.paymentdetails_status_sent_successful, payment.completedAt?.toRelativeDateString() ?: "")) - }, - imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, - isAnimated = fromEvent, - color = positiveColor, - ) - } - is ChannelCloseOutgoingPayment -> when (payment.confirmedAt) { - null -> { - PaymentStatusIcon( - message = null, - imageResId = R.drawable.ic_payment_details_pending_onchain_static, - isAnimated = false, - color = mutedTextColor, - ) - ConfirmationView(payment.txId, payment.channelId, isConfirmed = false, canBeBumped = false, onCpfpSuccess = onCpfpSuccess) - } - else -> { - PaymentStatusIcon( - message = { - Text(text = annotatedStringResource(id = R.string.paymentdetails_status_channelclose_confirmed, payment.completedAt?.toRelativeDateString() ?: "")) - }, - imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, - isAnimated = fromEvent, - color = positiveColor, - ) - ConfirmationView(payment.txId, payment.channelId, isConfirmed = true, canBeBumped = false, onCpfpSuccess) - } - } - is SpliceOutgoingPayment -> when (payment.confirmedAt) { - null -> { - PaymentStatusIcon( - message = null, - imageResId = R.drawable.ic_payment_details_pending_onchain_static, - isAnimated = false, - color = mutedTextColor, - ) - ConfirmationView(payment.txId, payment.channelId, isConfirmed = false, canBeBumped = true, onCpfpSuccess = onCpfpSuccess) - } - else -> { - PaymentStatusIcon( - message = { - Text(text = annotatedStringResource(id = R.string.paymentdetails_status_sent_successful, payment.completedAt!!.toRelativeDateString())) - }, - imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, - isAnimated = fromEvent, - color = positiveColor, - ) - ConfirmationView(payment.txId, payment.channelId, isConfirmed = true, canBeBumped = true, onCpfpSuccess = onCpfpSuccess) - } - } - is SpliceCpfpOutgoingPayment -> when (payment.confirmedAt) { - null -> { - PaymentStatusIcon( - message = null, - imageResId = R.drawable.ic_payment_details_pending_onchain_static, - isAnimated = false, - color = mutedTextColor, - ) - ConfirmationView(payment.txId, payment.channelId, isConfirmed = false, canBeBumped = true, onCpfpSuccess = onCpfpSuccess) - } - else -> { - PaymentStatusIcon( - message = { - Text(text = annotatedStringResource(id = R.string.paymentdetails_status_sent_successful, payment.completedAt!!.toRelativeDateString())) - }, - imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, - isAnimated = fromEvent, - color = positiveColor, - ) - ConfirmationView(payment.txId, payment.channelId, isConfirmed = true, canBeBumped = true, onCpfpSuccess = onCpfpSuccess) - } - } - is IncomingPayment -> { - val received = payment.received - when { - received == null -> { - PaymentStatusIcon( - message = { Text(text = stringResource(id = R.string.paymentdetails_status_received_pending)) }, - imageResId = R.drawable.ic_payment_details_pending_static, - isAnimated = false, - color = mutedTextColor - ) - } - received.receivedWith.isEmpty() -> { - PaymentStatusIcon( - message = { Text(text = stringResource(id = R.string.paymentdetails_status_received_paytoopen_pending)) }, - isAnimated = false, - imageResId = R.drawable.ic_clock, - color = mutedTextColor, - ) - } - received.receivedWith.any { it is IncomingPayment.ReceivedWith.OnChainIncomingPayment && it.lockedAt == null } -> { - PaymentStatusIcon( - message = { - Text(text = stringResource(id = R.string.paymentdetails_status_unconfirmed)) - }, - isAnimated = false, - imageResId = R.drawable.ic_clock, - color = mutedTextColor, - ) - } - payment.completedAt == null -> { - PaymentStatusIcon( - message = { - Text(text = stringResource(id = R.string.paymentdetails_status_received_pending)) - }, - imageResId = R.drawable.ic_payment_details_pending_static, - isAnimated = false, - color = mutedTextColor - ) - } - else -> { - PaymentStatusIcon( - message = { - Text(text = annotatedStringResource(id = R.string.paymentdetails_status_received_successful, payment.completedAt!!.toRelativeDateString())) - }, - imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, - isAnimated = fromEvent, - color = positiveColor, - ) - } - } - received?.receivedWith?.filterIsInstance()?.firstOrNull()?.let { - val nodeParams = business.nodeParamsManager.nodeParams.value - val channelMinDepth by produceState(initialValue = null, key1 = Unit) { - nodeParams?.let { params -> - val channelId = payment.received?.receivedWith?.filterIsInstance()?.firstOrNull()?.channelId - value = channelId?.let { peerManager.getChannelWithCommitments(it)?.minDepthForFunding(params) } - } - } - ConfirmationView(it.txId, it.channelId, isConfirmed = it.confirmedAt != null, canBeBumped = false, onCpfpSuccess = onCpfpSuccess, channelMinDepth) - } - } - is InboundLiquidityOutgoingPayment -> when (val lockedAt = payment.lockedAt) { - null -> { - PaymentStatusIcon( - message = null, - imageResId = R.drawable.ic_payment_details_pending_onchain_static, - isAnimated = false, - color = mutedTextColor, - ) - } - else -> { - PaymentStatusIcon( - message = { - Text(text = annotatedStringResource(id = R.string.paymentdetails_status_inbound_liquidity_success, lockedAt.toRelativeDateString())) - }, - imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, - isAnimated = fromEvent, - color = positiveColor, - ) - } - } - } -} - -@OptIn(ExperimentalAnimationGraphicsApi::class) -@Composable -private fun PaymentStatusIcon( - message: (@Composable ColumnScope.() -> Unit)?, - isAnimated: Boolean, - imageResId: Int, - color: Color, -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - val scope = rememberCoroutineScope() - var atEnd by remember { mutableStateOf(false) } - Image( - painter = if (isAnimated) { - rememberAnimatedVectorPainter(AnimatedImageVector.animatedVectorResource(imageResId), atEnd) - } else { - painterResource(id = imageResId) - }, - contentDescription = null, - colorFilter = ColorFilter.tint(color), - modifier = Modifier.size(80.dp) - ) - if (isAnimated) { - LaunchedEffect(key1 = Unit) { - scope.launch { - delay(150) - atEnd = true - } - } - } - message?.let { - Spacer(Modifier.height(16.dp)) - Column { it() } - } - } - -} - -@Composable -private fun LnurlPayInfoView(payment: LightningOutgoingPayment, metadata: LnurlPayMetadata) { - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_lnurlpay_service)) { - SelectionContainer { - Text(text = metadata.pay.callback.host) - } - } - metadata.successAction?.let { - LnurlSuccessAction(payment = payment, action = it) - } -} - -@Composable -private fun LnurlSuccessAction(payment: LightningOutgoingPayment, action: LnurlPay.Invoice.SuccessAction) { - Spacer(modifier = Modifier.height(8.dp)) - when (action) { - is LnurlPay.Invoice.SuccessAction.Message -> { - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_lnurlpay_action_message_label)) { - SelectionContainer { - Text(text = action.message) - } - } - } - is LnurlPay.Invoice.SuccessAction.Url -> { - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_lnurlpay_action_url_label)) { - Text(text = action.description) - WebLink(text = stringResource(id = R.string.paymentdetails_lnurlpay_action_url_button), url = action.url.toString()) - } - } - is LnurlPay.Invoice.SuccessAction.Aes -> { - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_lnurlpay_action_aes_label)) { - val status = payment.status - if (status is LightningOutgoingPayment.Status.Completed.Succeeded.OffChain) { - val deciphered by produceState(initialValue = null) { - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(status.preimage.toByteArray(), "AES"), IvParameterSpec(action.iv.toByteArray())) - value = String(cipher.doFinal(action.ciphertext.toByteArray()), Charsets.UTF_8) - } - Text(text = action.description) - when (deciphered) { - null -> ProgressView(text = stringResource(id = R.string.paymentdetails_lnurlpay_action_aes_decrypting), padding = PaddingValues(0.dp)) - else -> { - val url = try { - Url(deciphered!!) - } catch (e: Exception) { - null - } - if (url != null) { - WebLink(text = stringResource(id = R.string.paymentdetails_lnurlpay_action_url_button), url = url.toString()) - } else { - SelectionContainer { - Text(text = deciphered!!) - } - } - } - } - } else { - Text(text = stringResource(id = R.string.paymentdetails_lnurlpay_action_aes_decrypting)) - } - } - } - } -} - -@Composable -private fun OfferPayerNote(payerNote: String) { - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_offer_note_label)) { - Text(text = payerNote) - } -} - -@Composable -private fun OfferSentBy(payerPubkey: PublicKey?, hasPayerNote: Boolean) { - val contactsManager = business.contactsManager - val contactState = remember { mutableStateOf(OfferContactState.Init) } - LaunchedEffect(Unit) { - contactState.value = payerPubkey?.let { - contactsManager.getContactForPayerPubkey(it) - }?.let { OfferContactState.Found(it) } ?: OfferContactState.NotFound - } - - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_offer_sender_label)) { - when (val res = contactState.value) { - is OfferContactState.Init -> Text(text = stringResource(id = R.string.utils_loading_data)) - is OfferContactState.NotFound -> { - Text(text = stringResource(id = R.string.paymentdetails_offer_sender_unknown)) - if (hasPayerNote) { - Spacer(modifier = Modifier.height(4.dp)) - Text(text = stringResource(id = R.string.paymentdetails_offer_sender_unknown_details), style = MaterialTheme.typography.subtitle2) - } - } - is OfferContactState.Found -> { - ContactCompactView( - contact = res.contact, - currentOffer = null, - onContactChange = { contactState.value = if (it == null) OfferContactState.NotFound else OfferContactState.Found(it) }, - ) - } - } - } -} - -@Composable -private fun PaymentDescriptionView( - data: WalletPaymentInfo, - onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit, -) { - var showEditDescriptionDialog by remember { mutableStateOf(false) } - - val peer by business.peerManager.peerState.collectAsState() - val paymentDesc = data.metadata.lnurl?.description ?: data.payment.smartDescription(LocalContext.current) - val customDesc = remember(data) { data.metadata.userDescription?.takeIf { it.isNotBlank() } } - - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_desc_label)) { - val isLegacyMigration = data.isLegacyMigration(peer) - val finalDesc = when (isLegacyMigration) { - null -> stringResource(id = R.string.paymentdetails_desc_closing_channel) // not sure yet, but we still know it's a closing - true -> stringResource(id = R.string.paymentdetails_desc_legacy_migration) - false -> paymentDesc ?: customDesc - } - - if (isLegacyMigration == false) { - SplashClickableContent(onClick = { showEditDescriptionDialog = true }) { - Text( - text = finalDesc ?: stringResource(id = R.string.paymentdetails_no_description), - style = if (finalDesc == null) MaterialTheme.typography.caption.copy(fontStyle = FontStyle.Italic) else MaterialTheme.typography.body1 - ) - Spacer(modifier = Modifier.height(8.dp)) - if (paymentDesc != null && customDesc != null) { - HSeparator(width = 50.dp) - Spacer(modifier = Modifier.height(8.dp)) - Text(text = customDesc, style = MaterialTheme.typography.body1.copy(fontStyle = FontStyle.Italic)) - Spacer(modifier = Modifier.height(8.dp)) - } - TextWithIcon( - text = stringResource( - id = when (customDesc) { - null -> R.string.paymentdetails_attach_desc_button - else -> R.string.paymentdetails_edit_desc_button - } - ), - textStyle = MaterialTheme.typography.subtitle2, - icon = R.drawable.ic_edit, - iconTint = MaterialTheme.typography.subtitle2.color, - space = 6.dp, - ) - } - } - } - - if (showEditDescriptionDialog) { - CustomNoteDialog( - initialDescription = data.metadata.userDescription, - onConfirm = { - onMetadataDescriptionUpdate(data.id(), it?.trim()?.takeIf { it.isNotBlank() }) - showEditDescriptionDialog = false - }, - onDismiss = { showEditDescriptionDialog = false } - ) - } -} - -@Composable -private fun PaymentDestinationView(data: WalletPaymentInfo) { - when (val payment = data.payment) { - is InboundLiquidityOutgoingPayment -> {} - is OnChainOutgoingPayment -> { - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_destination_label), icon = R.drawable.ic_chain) { - SelectionContainer { - Text( - text = when (payment) { - is SpliceOutgoingPayment -> payment.address - is ChannelCloseOutgoingPayment -> payment.address - is SpliceCpfpOutgoingPayment -> stringResource(id = R.string.paymentdetails_destination_cpfp_value) - else -> stringResource(id = R.string.utils_unknown) - } - ) - } - } - } - is LightningOutgoingPayment -> { - val lnId = data.metadata.lnurl?.pay?.metadata?.lnid?.takeIf { it.isNotBlank() } - if (lnId != null) { - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_destination_label), icon = R.drawable.ic_zap) { - SelectionContainer { - Text(text = lnId) - } - } - } - - val details = payment.details - if (details is LightningOutgoingPayment.Details.Blinded) { - val offer = details.paymentRequest.invoiceRequest.offer - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_destination_label)) { - ContactOrOfferView(offer = offer) - } - } - } - else -> Unit - } -} - -@Composable -private fun PaymentFeeView(payment: WalletPayment) { - val btcUnit = LocalBitcoinUnit.current - when { - payment is LightningOutgoingPayment && (payment.state() == WalletPaymentState.SuccessOffChain) -> { - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_fees_label)) { - Text(text = payment.fees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) - } - } - payment is SpliceOutgoingPayment -> { - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_fees_label)) { - Text(text = payment.fees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) - } - } - payment is ChannelCloseOutgoingPayment -> { - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_fees_label)) { - Text(text = payment.fees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) - } - } - payment is SpliceCpfpOutgoingPayment -> { - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_fees_label)) { - Text(text = payment.fees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) - } - } - payment is InboundLiquidityOutgoingPayment -> { - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow( - label = stringResource(id = R.string.paymentdetails_liquidity_miner_fee_label), - helpMessage = stringResource(id = R.string.paymentdetails_liquidity_miner_fee_help) - ) { - Text(text = payment.miningFees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) - } - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow( - label = stringResource(id = R.string.paymentdetails_liquidity_service_fee_label), - helpMessage = stringResource(id = R.string.paymentdetails_liquidity_service_fee_help) - ) { - Text(text = payment.lease.fees.serviceFee.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) - } - } - payment is IncomingPayment -> { - val receivedWithNewChannel = payment.received?.receivedWith?.filterIsInstance() ?: emptyList() - val receivedWithSpliceIn = payment.received?.receivedWith?.filterIsInstance() ?: emptyList() - if ((receivedWithNewChannel + receivedWithSpliceIn).isNotEmpty()) { - val serviceFee = receivedWithNewChannel.map { it.serviceFee }.sum() + receivedWithSpliceIn.map { it.serviceFee }.sum() - val fundingFee = receivedWithNewChannel.map { it.miningFee }.sum() + receivedWithSpliceIn.map { it.miningFee }.sum() - Spacer(modifier = Modifier.height(8.dp)) - if (serviceFee > 0.msat) { - SplashLabelRow( - label = stringResource(id = R.string.paymentdetails_service_fees_label), - helpMessage = stringResource(R.string.paymentdetails_service_fees_desc) - ) { - Text(text = serviceFee.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW)) - } - Spacer(modifier = Modifier.height(8.dp)) - } - - SplashLabelRow( - label = stringResource(id = R.string.paymentdetails_funding_fees_label), - helpMessage = stringResource(R.string.paymentdetails_funding_fees_desc) - ) { - Text(text = fundingFee.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.HIDE)) - } - } - } - else -> {} - } -} - -@Composable -private fun InboundLiquidityLeaseDetails(lease: LiquidityAds.Lease) { - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_liquidity_lease_duration_label)) { - Text(text = stringResource(id = R.string.paymentdetails_liquidity_lease_duration_value)) - } -} - -@Composable -private fun PaymentErrorView(status: LightningOutgoingPayment.Status.Completed.Failed, failedParts: List) { - val failure = remember(status, failedParts) { OutgoingPaymentFailure(status.reason, failedParts) } - translatePaymentError(failure).let { - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_error_label)) { - Text(text = it) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun CustomNoteDialog( - initialDescription: String?, - onConfirm: (String?) -> Unit, - onDismiss: () -> Unit -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) - var description by rememberSaveable { mutableStateOf(initialDescription) } - - ModalBottomSheet( - sheetState = sheetState, - onDismissRequest = onDismiss, - containerColor = MaterialTheme.colors.surface, - contentColor = MaterialTheme.colors.onSurface, - scrimColor = MaterialTheme.colors.onBackground.copy(alpha = 0.1f), - ) { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(top = 0.dp, start = 24.dp, end = 24.dp, bottom = 70.dp), - ) { - Text(text = stringResource(id = R.string.paymentdetails_edit_dialog_title), style = MaterialTheme.typography.body2) - Spacer(modifier = Modifier.height(16.dp)) - TextInput( - modifier = Modifier.fillMaxWidth(), - text = description ?: "", - onTextChange = { description = it.takeIf { it.isNotBlank() } }, - minLines = 2, - maxLines = 6, - maxChars = 280, - staticLabel = stringResource(id = R.string.paymentdetails_edit_dialog_input_label) - ) - Spacer(modifier = Modifier.height(24.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - Button(onClick = onDismiss, text = stringResource(id = R.string.btn_cancel), shape = CircleShape) - Button( - onClick = { onConfirm(description) }, - text = stringResource(id = R.string.btn_save), - icon = R.drawable.ic_check, - enabled = description != initialDescription, - space = 8.dp, - shape = CircleShape - ) - } - } - } -} - -@Composable -private fun ConfirmationView( - txId: TxId, - channelId: ByteVector32, - isConfirmed: Boolean, - canBeBumped: Boolean, - onCpfpSuccess: () -> Unit, - minDepth: Int? = null, // sometimes we know how many confirmations are needed -) { - val txUrl = txUrl(txId = txId) - val context = LocalContext.current - val electrumClient = business.electrumClient - var showBumpTxDialog by remember { mutableStateOf(false) } - - if (isConfirmed) { - FilledButton( - text = stringResource(id = R.string.paymentdetails_status_confirmed), - icon = R.drawable.ic_chain, - backgroundColor = Color.Transparent, - padding = PaddingValues(8.dp), - textStyle = MaterialTheme.typography.button.copy(fontSize = 14.sp), - iconTint = MaterialTheme.colors.primary, - space = 6.dp, - onClick = { openLink(context, txUrl) } - ) - } else { - - suspend fun getConfirmations(): Int { - val confirmations = electrumClient.getConfirmations(txId) - return confirmations ?: run { - delay(5_000) - getConfirmations() - } - } - - val confirmations by produceState(initialValue = null) { - electrumClient.connectionStatus.filterIsInstance().first() - val confirmations = getConfirmations() - value = confirmations - } - confirmations?.let { conf -> - if (conf == 0) { - Card( - internalPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), - onClick = if (canBeBumped) { - { showBumpTxDialog = true } - } else null, - backgroundColor = Color.Transparent, - horizontalAlignment = Alignment.CenterHorizontally - ) { - TextWithIcon( - text = stringResource(R.string.paymentdetails_status_unconfirmed_zero), - icon = if (canBeBumped) R.drawable.ic_rocket else R.drawable.ic_clock, - textStyle = MaterialTheme.typography.button.copy(fontSize = 14.sp, color = MaterialTheme.colors.primary), - iconTint = MaterialTheme.colors.primary - ) - - if (canBeBumped) { - Text( - text = stringResource(id = R.string.paymentdetails_status_unconfirmed_zero_bump), - style = MaterialTheme.typography.button.copy(fontSize = 14.sp, color = MaterialTheme.colors.primary, fontWeight = FontWeight.Bold), - ) - } - } - } else { - FilledButton( - text = when (minDepth) { - null -> stringResource(R.string.paymentdetails_status_unconfirmed_default, conf) - else -> stringResource(R.string.paymentdetails_status_unconfirmed_with_depth, conf, minDepth) - }, - icon = R.drawable.ic_chain, - onClick = { openLink(context, txUrl) }, - backgroundColor = Color.Transparent, - padding = PaddingValues(8.dp), - textStyle = MaterialTheme.typography.button.copy(fontSize = 14.sp), - iconTint = MaterialTheme.colors.primary, - space = 6.dp, - ) - } - - if (conf == 0 && showBumpTxDialog) { - BumpTransactionDialog(channelId = channelId, onSuccess = onCpfpSuccess, onDismiss = { showBumpTxDialog = false }) - } - } ?: ProgressView( - text = stringResource(id = R.string.paymentdetails_status_unconfirmed_fetching), - textStyle = MaterialTheme.typography.body1.copy(fontSize = 14.sp), - padding = PaddingValues(8.dp), - progressCircleSize = 16.dp, - ) - } -} - -@Composable -private fun BumpTransactionDialog( - channelId: ByteVector32, - onSuccess: () -> Unit, - onDismiss: () -> Unit, -) { - Dialog( - onDismiss = onDismiss, - title = stringResource(id = R.string.cpfp_title), - buttons = null, - ) { - CpfpView(channelId = channelId, onSuccess = onSuccess) - } -} - -@Composable -fun translatePaymentError(paymentFailure: OutgoingPaymentFailure): String { - val context = LocalContext.current - val errorMessage = remember(key1 = paymentFailure) { - when (val result = paymentFailure.explain()) { - is Either.Left -> { - when (val partFailure = result.value) { - is LightningOutgoingPayment.Part.Status.Failure.Uninterpretable -> partFailure.message - LightningOutgoingPayment.Part.Status.Failure.ChannelIsClosing -> context.getString(R.string.outgoing_failuremessage_channel_closing) - LightningOutgoingPayment.Part.Status.Failure.ChannelIsSplicing -> context.getString(R.string.outgoing_failuremessage_channel_splicing) - LightningOutgoingPayment.Part.Status.Failure.NotEnoughFees -> context.getString(R.string.outgoing_failuremessage_not_enough_fee) - LightningOutgoingPayment.Part.Status.Failure.NotEnoughFunds -> context.getString(R.string.outgoing_failuremessage_not_enough_balance) - LightningOutgoingPayment.Part.Status.Failure.PaymentAmountTooBig -> context.getString(R.string.outgoing_failuremessage_too_big) - LightningOutgoingPayment.Part.Status.Failure.PaymentAmountTooSmall -> context.getString(R.string.outgoing_failuremessage_too_small) - LightningOutgoingPayment.Part.Status.Failure.PaymentExpiryTooBig -> context.getString(R.string.outgoing_failuremessage_expiry_too_big) - LightningOutgoingPayment.Part.Status.Failure.RecipientRejectedPayment -> context.getString(R.string.outgoing_failuremessage_rejected_by_recipient) - LightningOutgoingPayment.Part.Status.Failure.RecipientIsOffline -> context.getString(R.string.outgoing_failuremessage_recipient_offline) - LightningOutgoingPayment.Part.Status.Failure.RecipientLiquidityIssue -> context.getString(R.string.outgoing_failuremessage_not_enough_liquidity) - LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure -> context.getString(R.string.outgoing_failuremessage_temporary_failure) - LightningOutgoingPayment.Part.Status.Failure.TooManyPendingPayments -> context.getString(R.string.outgoing_failuremessage_too_many_pending) - } - } - is Either.Right -> { - when (result.value) { - FinalFailure.InvalidPaymentId -> context.getString(R.string.outgoing_failuremessage_invalid_id) - FinalFailure.AlreadyPaid -> context.getString(R.string.outgoing_failuremessage_alreadypaid) - FinalFailure.ChannelClosing -> context.getString(R.string.outgoing_failuremessage_channel_closing) - FinalFailure.ChannelNotConnected -> context.getString(R.string.outgoing_failuremessage_not_connected) - FinalFailure.ChannelOpening -> context.getString(R.string.outgoing_failuremessage_channel_opening) - FinalFailure.FeaturesNotSupported -> context.getString(R.string.outgoing_failuremessage_unsupported_features) - FinalFailure.InsufficientBalance -> context.getString(R.string.outgoing_failuremessage_not_enough_balance) - FinalFailure.InvalidPaymentAmount -> context.getString(R.string.outgoing_failuremessage_invalid_amount) - FinalFailure.NoAvailableChannels -> context.getString(R.string.outgoing_failuremessage_no_available_channels) - FinalFailure.RecipientUnreachable -> context.getString(R.string.outgoing_failuremessage_noroutefound) - FinalFailure.RetryExhausted -> context.getString(R.string.outgoing_failuremessage_noroutefound) - FinalFailure.UnknownError -> context.getString(R.string.outgoing_failuremessage_unknown) - FinalFailure.WalletRestarted -> context.getString(R.string.outgoing_failuremessage_restarted) - } - } - } - } - return errorMessage -} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsTechnicalView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsTechnicalView.kt index d08bc4d50..9ce0f9f68 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsTechnicalView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsTechnicalView.kt @@ -42,6 +42,7 @@ import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sum import fr.acinq.lightning.utils.toMilliSatoshi +import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.phoenix.android.LocalBitcoinUnit import fr.acinq.phoenix.android.LocalFiatCurrency import fr.acinq.phoenix.android.R @@ -50,16 +51,22 @@ import fr.acinq.phoenix.android.components.AmountView import fr.acinq.phoenix.android.components.Card import fr.acinq.phoenix.android.components.CardHeader import fr.acinq.phoenix.android.components.Clickable +import fr.acinq.phoenix.android.components.InlineButton import fr.acinq.phoenix.android.components.TextWithIcon import fr.acinq.phoenix.android.components.TransactionLinkButton import fr.acinq.phoenix.android.fiatRate +import fr.acinq.phoenix.android.navController +import fr.acinq.phoenix.android.navigateToPaymentDetails import fr.acinq.phoenix.android.utils.Converter.toAbsoluteDateTimeString import fr.acinq.phoenix.android.utils.Converter.toFiat import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.MSatDisplayPolicy import fr.acinq.phoenix.android.utils.copyToClipboard import fr.acinq.phoenix.data.ExchangeRate +import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.data.WalletPaymentInfo +import fr.acinq.phoenix.data.walletPaymentId +import fr.acinq.phoenix.utils.extensions.amountFeeCredit @Composable @@ -92,6 +99,7 @@ fun PaymentDetailsTechnicalView( is IncomingPayment.ReceivedWith.LightningPayment -> ReceivedWithLightning(it, rateThen) is IncomingPayment.ReceivedWith.NewChannel -> ReceivedWithNewChannel(it, rateThen) is IncomingPayment.ReceivedWith.SpliceIn -> ReceivedWithSpliceIn(it, rateThen) + is IncomingPayment.ReceivedWith.AddedToFeeCredit -> ReceivedWithFeeCredit(it, rateThen) } } } @@ -180,7 +188,7 @@ private fun HeaderForIncoming( // -- payment type TechnicalRow(label = stringResource(id = R.string.paymentdetails_payment_type_label)) { Text( - when (payment.origin) { + text = when (payment.origin) { is IncomingPayment.Origin.Invoice -> stringResource(R.string.paymentdetails_normal_incoming) is IncomingPayment.Origin.SwapIn -> stringResource(R.string.paymentdetails_swapin) is IncomingPayment.Origin.OnChain -> stringResource(R.string.paymentdetails_swapin) @@ -233,7 +241,7 @@ private fun AmountSection( is InboundLiquidityOutgoingPayment -> { TechnicalRowAmount( label = stringResource(id = R.string.paymentdetails_liquidity_amount_label), - amount = payment.lease.amount.toMilliSatoshi(), + amount = payment.purchase.amount.toMilliSatoshi(), rateThen = rateThen, mSatDisplayPolicy = MSatDisplayPolicy.SHOW ) @@ -245,14 +253,10 @@ private fun AmountSection( ) TechnicalRowAmount( label = stringResource(id = R.string.paymentdetails_liquidity_service_fee_label), - amount = payment.lease.fees.serviceFee.toMilliSatoshi(), + amount = payment.serviceFees.toMilliSatoshi(), rateThen = rateThen, mSatDisplayPolicy = MSatDisplayPolicy.SHOW ) - TechnicalRowSelectable( - label = stringResource(id = R.string.paymentdetails_liquidity_signature_label), - value = payment.lease.sellerSig.toHex(), - ) } is OutgoingPayment -> { TechnicalRowAmount( @@ -275,6 +279,14 @@ private fun AmountSection( rateThen = rateThen, mSatDisplayPolicy = MSatDisplayPolicy.SHOW ) + payment.amountFeeCredit?.let { + TechnicalRowAmount( + label = stringResource(R.string.paymentdetails_amount_fee_credit_label), + amount = it, + rateThen = rateThen, + mSatDisplayPolicy = MSatDisplayPolicy.SHOW + ) + } val receivedWithNewChannel = payment.received?.receivedWith?.filterIsInstance() ?: emptyList() val receivedWithSpliceIn = payment.received?.receivedWith?.filterIsInstance() ?: emptyList() if ((receivedWithNewChannel + receivedWithSpliceIn).isNotEmpty()) { @@ -383,6 +395,37 @@ private fun DetailsForInboundLiquidity( label = stringResource(id = R.string.paymentdetails_channel_id_label), value = payment.channelId.toHex(), ) + TechnicalRow(label = "Purchase Type") { + Text(text = when (payment.purchase) { + is LiquidityAds.Purchase.Standard -> "Standard" + is LiquidityAds.Purchase.WithFeeCredit -> "Fee credit" + }) + } + val details = payment.purchase.paymentDetails + TechnicalRow(label = "Purchase details") { + Text(text = details.paymentType.toString()) + } + when (details) { + is LiquidityAds.PaymentDetails.FromFutureHtlc -> ListLinksOfPaymentHashes(details.paymentHashes) + is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> ListLinksOfPaymentHashes(details.paymentHashes) + else -> Unit + } +} + +@Composable +private fun ListLinksOfPaymentHashes(paymentHashes: List) { + val navController = navController + TechnicalRow(label = "Triggered by payments") { + Column { + paymentHashes.forEach { + InlineButton( + text = "- ${it.toHex()}", + onClick = { navigateToPaymentDetails(navController, WalletPaymentId.IncomingPaymentId(it), isFromEvent = false) }, + maxLines = 1, + ) + } + } + } } @Composable @@ -449,7 +492,7 @@ private fun ReceivedWithLightning( Text(text = receivedWith.channelId.toHex()) } } - TechnicalRowAmount(label = stringResource(id = R.string.paymentdetails_amount_received_label), amount = receivedWith.amount, rateThen = rateThen) + TechnicalRowAmount(label = stringResource(id = R.string.paymentdetails_amount_received_label), amount = receivedWith.amountReceived, rateThen = rateThen) } @Composable @@ -470,7 +513,7 @@ private fun ReceivedWithNewChannel( label = stringResource(id = R.string.paymentdetails_tx_id_label), content = { TransactionLinkButton(txId = receivedWith.txId) } ) - TechnicalRowAmount(label = stringResource(id = R.string.paymentdetails_amount_received_label), amount = receivedWith.amount, rateThen = rateThen) + TechnicalRowAmount(label = stringResource(id = R.string.paymentdetails_amount_received_label), amount = receivedWith.amountReceived, rateThen = rateThen) } @Composable @@ -491,7 +534,18 @@ private fun ReceivedWithSpliceIn( label = stringResource(id = R.string.paymentdetails_tx_id_label), content = { TransactionLinkButton(txId = receivedWith.txId) } ) - TechnicalRowAmount(label = stringResource(id = R.string.paymentdetails_amount_received_label), amount = receivedWith.amount, rateThen = rateThen) + TechnicalRowAmount(label = stringResource(id = R.string.paymentdetails_amount_received_label), amount = receivedWith.amountReceived, rateThen = rateThen) +} + +@Composable +private fun ReceivedWithFeeCredit( + receivedWith: IncomingPayment.ReceivedWith.AddedToFeeCredit, + rateThen: ExchangeRate.BitcoinPriceRate? +) { + TechnicalRow(label = stringResource(id = R.string.paymentdetails_received_with_label)) { + Text(text = stringResource(id = R.string.paymentdetails_received_with_fee_credit)) + } + TechnicalRowAmount(label = stringResource(id = R.string.paymentdetails_amount_added_to_fee_credit_label), amount = receivedWith.amountReceived, rateThen = rateThen) } @Composable diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsView.kt index d46179c85..ab4bdb395 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsView.kt @@ -36,6 +36,7 @@ import fr.acinq.phoenix.android.components.Card import fr.acinq.phoenix.android.components.DefaultScreenHeader import fr.acinq.phoenix.android.components.DefaultScreenLayout import fr.acinq.phoenix.android.components.feedback.ErrorMessage +import fr.acinq.phoenix.android.payments.details.splash.PaymentDetailsSplashView import fr.acinq.phoenix.data.WalletPaymentFetchOptions import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.data.WalletPaymentInfo diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentLine.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentLine.kt index 575dc9b0b..084074887 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentLine.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentLine.kt @@ -181,7 +181,7 @@ private fun PaymentDescription( val metadata = paymentInfo.metadata val peer by business.peerManager.peerState.collectAsState() - val desc = when (paymentInfo.isLegacyMigration(peer)) { + val desc = when (payment.isLegacyMigration(metadata, peer)) { null -> stringResource(id = R.string.paymentdetails_desc_closing_channel) // not sure yet, but we still know it's a closing true -> stringResource(id = R.string.paymentdetails_desc_legacy_migration) false -> metadata.userDescription diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/PaymentSplashStatus.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/PaymentSplashStatus.kt new file mode 100644 index 000000000..0eb146a6c --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/PaymentSplashStatus.kt @@ -0,0 +1,388 @@ +/* + * 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.payments.details.splash + +import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi +import androidx.compose.animation.graphics.res.animatedVectorResource +import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter +import androidx.compose.animation.graphics.vector.AnimatedImageVector +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.TxId +import fr.acinq.lightning.blockchain.electrum.ElectrumConnectionStatus +import fr.acinq.lightning.db.ChannelCloseOutgoingPayment +import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment +import fr.acinq.lightning.db.IncomingPayment +import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment +import fr.acinq.lightning.db.SpliceOutgoingPayment +import fr.acinq.lightning.db.WalletPayment +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.business +import fr.acinq.phoenix.android.components.Card +import fr.acinq.phoenix.android.components.Dialog +import fr.acinq.phoenix.android.components.FilledButton +import fr.acinq.phoenix.android.components.ProgressView +import fr.acinq.phoenix.android.components.TextWithIcon +import fr.acinq.phoenix.android.components.openLink +import fr.acinq.phoenix.android.components.txUrl +import fr.acinq.phoenix.android.payments.cpfp.CpfpView +import fr.acinq.phoenix.android.utils.Converter.toRelativeDateString +import fr.acinq.phoenix.android.utils.annotatedStringResource +import fr.acinq.phoenix.android.utils.mutedTextColor +import fr.acinq.phoenix.android.utils.negativeColor +import fr.acinq.phoenix.android.utils.positiveColor +import fr.acinq.phoenix.utils.extensions.minDepthForFunding +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.math.absoluteValue + +@Composable +fun PaymentStatus( + payment: WalletPayment, + fromEvent: Boolean, + onCpfpSuccess: () -> Unit, +) { + val peerManager = business.peerManager + when (payment) { + is LightningOutgoingPayment -> when (payment.status) { + is LightningOutgoingPayment.Status.Pending -> PaymentStatusIcon( + message = { Text(text = stringResource(id = R.string.paymentdetails_status_sent_pending)) }, + imageResId = R.drawable.ic_payment_details_pending_static, + isAnimated = false, + color = mutedTextColor + ) + is LightningOutgoingPayment.Status.Completed.Failed -> PaymentStatusIcon( + message = { Text(text = annotatedStringResource(id = R.string.paymentdetails_status_sent_failed), textAlign = TextAlign.Center) }, + imageResId = R.drawable.ic_payment_details_failure_static, + isAnimated = false, + color = negativeColor + ) + is LightningOutgoingPayment.Status.Completed.Succeeded -> PaymentStatusIcon( + message = { + Text(text = annotatedStringResource(id = R.string.paymentdetails_status_sent_successful, payment.completedAt?.toRelativeDateString() ?: "")) + }, + imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, + isAnimated = fromEvent, + color = positiveColor, + ) + } + is ChannelCloseOutgoingPayment -> when (payment.confirmedAt) { + null -> { + PaymentStatusIcon( + message = null, + imageResId = R.drawable.ic_payment_details_pending_onchain_static, + isAnimated = false, + color = mutedTextColor, + ) + ConfirmationView(payment.txId, payment.channelId, isConfirmed = false, canBeBumped = false, onCpfpSuccess = onCpfpSuccess) + } + else -> { + PaymentStatusIcon( + message = { + Text(text = annotatedStringResource(id = R.string.paymentdetails_status_channelclose_confirmed, payment.completedAt?.toRelativeDateString() ?: "")) + }, + imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, + isAnimated = fromEvent, + color = positiveColor, + ) + ConfirmationView(payment.txId, payment.channelId, isConfirmed = true, canBeBumped = false, onCpfpSuccess) + } + } + is SpliceOutgoingPayment -> when (payment.confirmedAt) { + null -> { + PaymentStatusIcon( + message = null, + imageResId = R.drawable.ic_payment_details_pending_onchain_static, + isAnimated = false, + color = mutedTextColor, + ) + ConfirmationView(payment.txId, payment.channelId, isConfirmed = false, canBeBumped = true, onCpfpSuccess = onCpfpSuccess) + } + else -> { + PaymentStatusIcon( + message = { + Text(text = annotatedStringResource(id = R.string.paymentdetails_status_sent_successful, payment.completedAt!!.toRelativeDateString())) + }, + imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, + isAnimated = fromEvent, + color = positiveColor, + ) + ConfirmationView(payment.txId, payment.channelId, isConfirmed = true, canBeBumped = true, onCpfpSuccess = onCpfpSuccess) + } + } + is SpliceCpfpOutgoingPayment -> when (payment.confirmedAt) { + null -> { + PaymentStatusIcon( + message = null, + imageResId = R.drawable.ic_payment_details_pending_onchain_static, + isAnimated = false, + color = mutedTextColor, + ) + ConfirmationView(payment.txId, payment.channelId, isConfirmed = false, canBeBumped = true, onCpfpSuccess = onCpfpSuccess) + } + else -> { + PaymentStatusIcon( + message = { + Text(text = annotatedStringResource(id = R.string.paymentdetails_status_sent_successful, payment.completedAt!!.toRelativeDateString())) + }, + imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, + isAnimated = fromEvent, + color = positiveColor, + ) + ConfirmationView(payment.txId, payment.channelId, isConfirmed = true, canBeBumped = true, onCpfpSuccess = onCpfpSuccess) + } + } + is IncomingPayment -> { + val received = payment.received + when { + received == null -> { + PaymentStatusIcon( + message = { Text(text = stringResource(id = R.string.paymentdetails_status_received_pending)) }, + imageResId = R.drawable.ic_payment_details_pending_static, + isAnimated = false, + color = mutedTextColor + ) + } + received.receivedWith.isEmpty() -> { + PaymentStatusIcon( + message = { Text(text = stringResource(id = R.string.paymentdetails_status_received_paytoopen_pending)) }, + isAnimated = false, + imageResId = R.drawable.ic_clock, + color = mutedTextColor, + ) + } + received.receivedWith.any { it is IncomingPayment.ReceivedWith.OnChainIncomingPayment && it.lockedAt == null } -> { + PaymentStatusIcon( + message = { + Text(text = stringResource(id = R.string.paymentdetails_status_unconfirmed)) + }, + isAnimated = false, + imageResId = R.drawable.ic_clock, + color = mutedTextColor, + ) + } + payment.completedAt == null -> { + PaymentStatusIcon( + message = { + Text(text = stringResource(id = R.string.paymentdetails_status_received_pending)) + }, + imageResId = R.drawable.ic_payment_details_pending_static, + isAnimated = false, + color = mutedTextColor + ) + } + else -> { + PaymentStatusIcon( + message = { + Text(text = annotatedStringResource(id = R.string.paymentdetails_status_received_successful, payment.completedAt!!.toRelativeDateString())) + }, + imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, + isAnimated = fromEvent, + color = positiveColor, + ) + } + } + received?.receivedWith?.filterIsInstance()?.firstOrNull()?.let { + val nodeParams = business.nodeParamsManager.nodeParams.value + val channelMinDepth by produceState(initialValue = null, key1 = Unit) { + nodeParams?.let { params -> + val channelId = payment.received?.receivedWith?.filterIsInstance()?.firstOrNull()?.channelId + value = channelId?.let { peerManager.getChannelWithCommitments(it)?.minDepthForFunding(params) } + } + } + ConfirmationView(it.txId, it.channelId, isConfirmed = it.confirmedAt != null, canBeBumped = false, onCpfpSuccess = onCpfpSuccess, channelMinDepth) + } + } + is InboundLiquidityOutgoingPayment -> SplashLiquidityStatus(payment = payment, fromEvent = fromEvent) + } +} + +@OptIn(ExperimentalAnimationGraphicsApi::class) +@Composable +fun PaymentStatusIcon( + message: (@Composable ColumnScope.() -> Unit)?, + isAnimated: Boolean, + imageResId: Int, + color: Color, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + val scope = rememberCoroutineScope() + var atEnd by remember { mutableStateOf(false) } + Image( + painter = if (isAnimated) { + rememberAnimatedVectorPainter(AnimatedImageVector.animatedVectorResource(imageResId), atEnd) + } else { + painterResource(id = imageResId) + }, + contentDescription = null, + colorFilter = ColorFilter.tint(color), + modifier = Modifier.size(80.dp) + ) + if (isAnimated) { + LaunchedEffect(key1 = Unit) { + scope.launch { + delay(150) + atEnd = true + } + } + } + message?.let { + Spacer(Modifier.height(16.dp)) + Column { it() } + } + } + +} + +@Composable +private fun ConfirmationView( + txId: TxId, + channelId: ByteVector32, + isConfirmed: Boolean, + canBeBumped: Boolean, + onCpfpSuccess: () -> Unit, + minDepth: Int? = null, // sometimes we know how many confirmations are needed +) { + val txUrl = txUrl(txId = txId) + val context = LocalContext.current + val electrumClient = business.electrumClient + var showBumpTxDialog by remember { mutableStateOf(false) } + + if (isConfirmed) { + FilledButton( + text = stringResource(id = R.string.paymentdetails_status_confirmed), + icon = R.drawable.ic_chain, + backgroundColor = Color.Transparent, + padding = PaddingValues(8.dp), + textStyle = MaterialTheme.typography.button.copy(fontSize = 14.sp), + iconTint = MaterialTheme.colors.primary, + space = 6.dp, + onClick = { openLink(context, txUrl) } + ) + } else { + + suspend fun getConfirmations(): Int { + val confirmations = electrumClient.getConfirmations(txId) + return confirmations ?: run { + delay(5_000) + getConfirmations() + } + } + + val confirmations by produceState(initialValue = null) { + electrumClient.connectionStatus.filterIsInstance().first() + val confirmations = getConfirmations() + value = confirmations + } + confirmations?.absoluteValue?.let { conf -> + if (conf == 0) { + Card( + internalPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + onClick = if (canBeBumped) { + { showBumpTxDialog = true } + } else null, + backgroundColor = Color.Transparent, + horizontalAlignment = Alignment.CenterHorizontally + ) { + TextWithIcon( + text = stringResource(R.string.paymentdetails_status_unconfirmed_zero), + icon = if (canBeBumped) R.drawable.ic_rocket else R.drawable.ic_clock, + textStyle = MaterialTheme.typography.button.copy(fontSize = 14.sp, color = MaterialTheme.colors.primary), + iconTint = MaterialTheme.colors.primary + ) + + if (canBeBumped) { + Text( + text = stringResource(id = R.string.paymentdetails_status_unconfirmed_zero_bump), + style = MaterialTheme.typography.button.copy(fontSize = 14.sp, color = MaterialTheme.colors.primary, fontWeight = FontWeight.Bold), + ) + } + } + } else { + FilledButton( + text = when (minDepth) { + null -> stringResource(R.string.paymentdetails_status_unconfirmed_default, conf) + else -> stringResource(R.string.paymentdetails_status_unconfirmed_with_depth, conf, minDepth) + }, + icon = R.drawable.ic_chain, + onClick = { openLink(context, txUrl) }, + backgroundColor = Color.Transparent, + padding = PaddingValues(8.dp), + textStyle = MaterialTheme.typography.button.copy(fontSize = 14.sp), + iconTint = MaterialTheme.colors.primary, + space = 6.dp, + ) + } + + if (conf == 0 && showBumpTxDialog) { + BumpTransactionDialog(channelId = channelId, onSuccess = onCpfpSuccess, onDismiss = { showBumpTxDialog = false }) + } + } ?: ProgressView( + text = stringResource(id = R.string.paymentdetails_status_unconfirmed_fetching), + textStyle = MaterialTheme.typography.body1.copy(fontSize = 14.sp), + padding = PaddingValues(8.dp), + progressCircleSize = 16.dp, + ) + } +} + +@Composable +private fun BumpTransactionDialog( + channelId: ByteVector32, + onSuccess: () -> Unit, + onDismiss: () -> Unit, +) { + Dialog( + onDismiss = onDismiss, + title = stringResource(id = R.string.cpfp_title), + buttons = null, + ) { + CpfpView(channelId = channelId, onSuccess = onSuccess) + } +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/PaymentSplashView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/PaymentSplashView.kt new file mode 100644 index 000000000..882ef195e --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/PaymentSplashView.kt @@ -0,0 +1,233 @@ +/* + * 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.payments.details.splash + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import fr.acinq.lightning.db.ChannelCloseOutgoingPayment +import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment +import fr.acinq.lightning.db.IncomingPayment +import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.lightning.db.OutgoingPayment +import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment +import fr.acinq.lightning.db.SpliceOutgoingPayment +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.AmountView +import fr.acinq.phoenix.android.components.BorderButton +import fr.acinq.phoenix.android.components.Button +import fr.acinq.phoenix.android.components.DefaultScreenHeader +import fr.acinq.phoenix.android.components.PrimarySeparator +import fr.acinq.phoenix.android.components.SplashClickableContent +import fr.acinq.phoenix.android.components.SplashLabelRow +import fr.acinq.phoenix.android.components.SplashLayout +import fr.acinq.phoenix.android.components.TextInput +import fr.acinq.phoenix.android.components.TextWithIcon +import fr.acinq.phoenix.android.utils.borderColor +import fr.acinq.phoenix.android.utils.mutedBgColor +import fr.acinq.phoenix.android.utils.negativeColor +import fr.acinq.phoenix.android.utils.positiveColor +import fr.acinq.phoenix.data.WalletPaymentId +import fr.acinq.phoenix.data.WalletPaymentInfo +import fr.acinq.phoenix.utils.extensions.WalletPaymentState +import fr.acinq.phoenix.utils.extensions.state + +@Composable +fun PaymentDetailsSplashView( + onBackClick: () -> Unit, + data: WalletPaymentInfo, + onDetailsClick: (WalletPaymentId) -> Unit, + onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit, + fromEvent: Boolean, +) { + val payment = data.payment + SplashLayout( + header = { DefaultScreenHeader(onBackClick = onBackClick) }, + topContent = { PaymentStatus(data.payment, fromEvent, onCpfpSuccess = onBackClick) } + ) { + AmountView( + amount = when (payment) { + is InboundLiquidityOutgoingPayment -> payment.amount + is OutgoingPayment -> payment.amount - payment.fees + is IncomingPayment -> payment.amount + }, + amountTextStyle = MaterialTheme.typography.body1.copy(fontSize = 30.sp), + separatorSpace = 4.dp, + prefix = stringResource(id = if (payment is OutgoingPayment) R.string.paymentline_prefix_sent else R.string.paymentline_prefix_received) + + ) + + Spacer(modifier = Modifier.height(36.dp)) + PrimarySeparator( + height = 6.dp, + color = when (payment.state()) { + WalletPaymentState.Failure -> negativeColor + WalletPaymentState.SuccessOffChain, WalletPaymentState.SuccessOnChain -> positiveColor + else -> mutedBgColor + } + ) + Spacer(modifier = Modifier.height(36.dp)) + + when (val payment = data.payment) { + is IncomingPayment -> SplashIncoming(payment = payment, metadata = data.metadata, onMetadataDescriptionUpdate = onMetadataDescriptionUpdate) + is LightningOutgoingPayment -> SplashLightningOutgoing(payment = payment, metadata = data.metadata, onMetadataDescriptionUpdate = onMetadataDescriptionUpdate) + is ChannelCloseOutgoingPayment -> SplashChannelClose(payment = payment, metadata = data.metadata, onMetadataDescriptionUpdate = onMetadataDescriptionUpdate) + is SpliceCpfpOutgoingPayment -> SplashSpliceOutCpfp(payment = payment, metadata = data.metadata, onMetadataDescriptionUpdate = onMetadataDescriptionUpdate) + is SpliceOutgoingPayment -> SplashSpliceOut(payment = payment, metadata = data.metadata, onMetadataDescriptionUpdate = onMetadataDescriptionUpdate) + is InboundLiquidityOutgoingPayment -> SplashLiquidityPurchase(payment = payment, metadata = data.metadata, onMetadataDescriptionUpdate = onMetadataDescriptionUpdate) + } + + Spacer(modifier = Modifier.height(48.dp)) + BorderButton( + text = stringResource(id = R.string.paymentdetails_details_button), + borderColor = borderColor, + textStyle = MaterialTheme.typography.caption, + icon = R.drawable.ic_tool, + iconTint = MaterialTheme.typography.caption.color, + onClick = { onDetailsClick(data.id()) }, + ) + } +} + +@Composable +fun SplashDescription( + description: String?, + userDescription: String?, + paymentId: WalletPaymentId, + onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit, +) { + var showEditDescriptionDialog by remember { mutableStateOf(false) } + + Spacer(modifier = Modifier.height(8.dp)) + + if (!(description.isNullOrBlank() && !userDescription.isNullOrBlank())) { + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_desc_label)) { + if (description.isNullOrBlank()) { + Text( + text = stringResource(id = R.string.paymentdetails_no_description), + style = MaterialTheme.typography.caption.copy(fontStyle = FontStyle.Italic) + ) + } else { + Text(text = description) + } + } + } + + SplashLabelRow(label = if (userDescription.isNullOrBlank()) "" else "Note") { + SplashClickableContent(onClick = { showEditDescriptionDialog = true }) { + if (!userDescription.isNullOrBlank()) { + Text(text = userDescription) + Spacer(modifier = Modifier.height(8.dp)) + } + TextWithIcon( + text = stringResource( + id = when (userDescription) { + null -> R.string.paymentdetails_attach_desc_button + else -> R.string.paymentdetails_edit_desc_button + } + ), + textStyle = MaterialTheme.typography.subtitle2, + icon = R.drawable.ic_edit, + iconTint = MaterialTheme.typography.subtitle2.color, + space = 6.dp, + ) + } + } + + if (showEditDescriptionDialog) { + CustomNoteDialog( + initialDescription = userDescription, + onConfirm = { + onMetadataDescriptionUpdate(paymentId, it?.trim()?.takeIf { it.isNotBlank() }) + showEditDescriptionDialog = false + }, + onDismiss = { showEditDescriptionDialog = false } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CustomNoteDialog( + initialDescription: String?, + onConfirm: (String?) -> Unit, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) + var description by rememberSaveable { mutableStateOf(initialDescription) } + + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = onDismiss, + containerColor = MaterialTheme.colors.surface, + contentColor = MaterialTheme.colors.onSurface, + scrimColor = MaterialTheme.colors.onBackground.copy(alpha = 0.1f), + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(top = 0.dp, start = 24.dp, end = 24.dp, bottom = 70.dp), + ) { + Text(text = stringResource(id = R.string.paymentdetails_edit_dialog_title), style = MaterialTheme.typography.body2) + Spacer(modifier = Modifier.height(16.dp)) + TextInput( + modifier = Modifier.fillMaxWidth(), + text = description ?: "", + onTextChange = { description = it.takeIf { it.isNotBlank() } }, + minLines = 2, + maxLines = 6, + maxChars = 280, + staticLabel = stringResource(id = R.string.paymentdetails_edit_dialog_input_label) + ) + Spacer(modifier = Modifier.height(24.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + Button(onClick = onDismiss, text = stringResource(id = R.string.btn_cancel), shape = CircleShape) + Button( + onClick = { onConfirm(description) }, + text = stringResource(id = R.string.btn_save), + icon = R.drawable.ic_check, + enabled = description != initialDescription, + space = 8.dp, + shape = CircleShape + ) + } + } + } +} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashChannelClose.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashChannelClose.kt new file mode 100644 index 000000000..8c9cd8a25 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashChannelClose.kt @@ -0,0 +1,86 @@ +/* + * 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.payments.details.splash + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import fr.acinq.lightning.db.ChannelCloseOutgoingPayment +import fr.acinq.phoenix.android.LocalBitcoinUnit +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.business +import fr.acinq.phoenix.android.components.SplashLabelRow +import fr.acinq.phoenix.android.utils.Converter.toPrettyString +import fr.acinq.phoenix.android.utils.MSatDisplayPolicy +import fr.acinq.phoenix.android.utils.isLegacyMigration +import fr.acinq.phoenix.android.utils.smartDescription +import fr.acinq.phoenix.data.WalletPaymentId +import fr.acinq.phoenix.data.WalletPaymentMetadata +import fr.acinq.phoenix.data.walletPaymentId + +@Composable +fun SplashChannelClose( + payment: ChannelCloseOutgoingPayment, + metadata: WalletPaymentMetadata, + onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit, +) { + val context = LocalContext.current + val peer by business.peerManager.peerState.collectAsState() + + val isLegacyMigration = payment.isLegacyMigration(metadata, peer) + val description = when (isLegacyMigration) { + null -> stringResource(id = R.string.paymentdetails_desc_closing_channel) // not sure yet, but we still know it's a closing + true -> stringResource(id = R.string.paymentdetails_desc_legacy_migration) + false -> payment.smartDescription(context) + } + + SplashDescription( + description = description, + userDescription = metadata.userDescription, + paymentId = payment.walletPaymentId(), + onMetadataDescriptionUpdate = onMetadataDescriptionUpdate + ) + SplashDestination(payment, metadata) + SplashFee(payment = payment) +} + +@Composable +private fun SplashDestination(payment: ChannelCloseOutgoingPayment, metadata: WalletPaymentMetadata) { + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_destination_label), icon = R.drawable.ic_chain) { + SelectionContainer { + Text(text = payment.address) + } + } +} + +@Composable +private fun SplashFee(payment: ChannelCloseOutgoingPayment) { + val btcUnit = LocalBitcoinUnit.current + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_fees_label)) { + Text(text = payment.fees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) + } +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashIncoming.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashIncoming.kt new file mode 100644 index 000000000..2bd160c65 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashIncoming.kt @@ -0,0 +1,141 @@ +/* + * 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.payments.details.splash + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import fr.acinq.bitcoin.PublicKey +import fr.acinq.lightning.db.IncomingPayment +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sum +import fr.acinq.phoenix.android.LocalBitcoinUnit +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.business +import fr.acinq.phoenix.android.components.SplashLabelRow +import fr.acinq.phoenix.android.components.contact.ContactCompactView +import fr.acinq.phoenix.android.components.contact.OfferContactState +import fr.acinq.phoenix.android.utils.Converter.toPrettyString +import fr.acinq.phoenix.android.utils.MSatDisplayPolicy +import fr.acinq.phoenix.android.utils.smartDescription +import fr.acinq.phoenix.data.WalletPaymentId +import fr.acinq.phoenix.data.WalletPaymentMetadata +import fr.acinq.phoenix.data.walletPaymentId +import fr.acinq.phoenix.utils.extensions.incomingOfferMetadata + +@Composable +fun SplashIncoming( + payment: IncomingPayment, + metadata: WalletPaymentMetadata, + onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit, +) { + val context = LocalContext.current + + payment.incomingOfferMetadata()?.let { meta -> + meta.payerNote?.takeIf { it.isNotBlank() }?.let { + OfferPayerNote(payerNote = it) + Spacer(modifier = Modifier.height(8.dp)) + } + OfferSentBy(payerPubkey = meta.payerKey, !meta.payerNote.isNullOrBlank()) + } + + SplashDescription( + description = payment.smartDescription(context = context), + userDescription = metadata.userDescription, + paymentId = payment.walletPaymentId(), + onMetadataDescriptionUpdate = onMetadataDescriptionUpdate, + ) + SplashFee(payment) +} + +@Composable +fun OfferPayerNote(payerNote: String) { + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_offer_note_label)) { + Text(text = payerNote) + } +} + +@Composable +private fun OfferSentBy(payerPubkey: PublicKey?, hasPayerNote: Boolean) { + val contactsManager = business.contactsManager + val contactState = remember { mutableStateOf(OfferContactState.Init) } + LaunchedEffect(Unit) { + contactState.value = payerPubkey?.let { + contactsManager.getContactForPayerPubkey(it) + }?.let { OfferContactState.Found(it) } ?: OfferContactState.NotFound + } + + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_offer_sender_label)) { + when (val res = contactState.value) { + is OfferContactState.Init -> Text(text = stringResource(id = R.string.utils_loading_data)) + is OfferContactState.NotFound -> { + Text(text = stringResource(id = R.string.paymentdetails_offer_sender_unknown)) + if (hasPayerNote) { + Spacer(modifier = Modifier.height(4.dp)) + Text(text = stringResource(id = R.string.paymentdetails_offer_sender_unknown_details), style = MaterialTheme.typography.subtitle2) + } + } + is OfferContactState.Found -> { + ContactCompactView( + contact = res.contact, + currentOffer = null, + onContactChange = { contactState.value = if (it == null) OfferContactState.NotFound else OfferContactState.Found(it) }, + ) + } + } + } +} + +@Composable +private fun SplashFee( + payment: IncomingPayment +) { + val btcUnit = LocalBitcoinUnit.current + val receivedWithNewChannel = payment.received?.receivedWith?.filterIsInstance() ?: emptyList() + val receivedWithSpliceIn = payment.received?.receivedWith?.filterIsInstance() ?: emptyList() + if ((receivedWithNewChannel + receivedWithSpliceIn).isNotEmpty()) { + val serviceFee = receivedWithNewChannel.map { it.serviceFee }.sum() + receivedWithSpliceIn.map { it.serviceFee }.sum() + val fundingFee = receivedWithNewChannel.map { it.miningFee }.sum() + receivedWithSpliceIn.map { it.miningFee }.sum() + Spacer(modifier = Modifier.height(8.dp)) + if (serviceFee > 0.msat) { + SplashLabelRow( + label = stringResource(id = R.string.paymentdetails_service_fees_label), + helpMessage = stringResource(R.string.paymentdetails_service_fees_desc) + ) { + Text(text = serviceFee.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW)) + } + Spacer(modifier = Modifier.height(8.dp)) + } + + SplashLabelRow( + label = stringResource(id = R.string.paymentdetails_funding_fees_label), + helpMessage = stringResource(R.string.paymentdetails_funding_fees_desc) + ) { + Text(text = fundingFee.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.HIDE)) + } + } +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashLightningOut.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashLightningOut.kt new file mode 100644 index 000000000..cde352f3c --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashLightningOut.kt @@ -0,0 +1,237 @@ +/* + * 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.payments.details.splash + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import fr.acinq.bitcoin.utils.Either +import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.lightning.payment.FinalFailure +import fr.acinq.lightning.payment.OutgoingPaymentFailure +import fr.acinq.phoenix.android.LocalBitcoinUnit +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.ProgressView +import fr.acinq.phoenix.android.components.SplashLabelRow +import fr.acinq.phoenix.android.components.WebLink +import fr.acinq.phoenix.android.components.contact.ContactOrOfferView +import fr.acinq.phoenix.android.utils.Converter.toPrettyString +import fr.acinq.phoenix.android.utils.MSatDisplayPolicy +import fr.acinq.phoenix.android.utils.smartDescription +import fr.acinq.phoenix.data.LnurlPayMetadata +import fr.acinq.phoenix.data.WalletPaymentId +import fr.acinq.phoenix.data.WalletPaymentMetadata +import fr.acinq.phoenix.data.lnurl.LnurlPay +import fr.acinq.phoenix.data.walletPaymentId +import fr.acinq.phoenix.utils.extensions.WalletPaymentState +import fr.acinq.phoenix.utils.extensions.outgoingInvoiceRequest +import fr.acinq.phoenix.utils.extensions.state +import io.ktor.http.Url +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +@Composable +fun SplashLightningOutgoing( + payment: LightningOutgoingPayment, + metadata: WalletPaymentMetadata, + onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit, +) { + val context = LocalContext.current + + metadata.lnurl?.let { lnurlMeta -> + LnurlPayInfoView(payment, lnurlMeta) + } + + payment.outgoingInvoiceRequest()?.payerNote?.takeIf { it.isNotBlank() }?.let { + OfferPayerNote(payerNote = it) + } + + SplashDescription( + description = payment.smartDescription(context), + userDescription = metadata.userDescription, + paymentId = payment.walletPaymentId(), + onMetadataDescriptionUpdate = onMetadataDescriptionUpdate + ) + SplashDestination(payment, metadata) + SplashFee(payment = payment) + + (payment.status as? LightningOutgoingPayment.Status.Completed.Failed)?.let { status -> + PaymentErrorView(status = status, failedParts = payment.parts.map { it.status }.filterIsInstance()) + } +} + +@Composable +private fun SplashDestination(payment: LightningOutgoingPayment, metadata: WalletPaymentMetadata) { + val lnId = metadata.lnurl?.pay?.metadata?.lnid?.takeIf { it.isNotBlank() } + if (lnId != null) { + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_destination_label), icon = R.drawable.ic_zap) { + SelectionContainer { + Text(text = lnId) + } + } + } + val details = payment.details + if (details is LightningOutgoingPayment.Details.Blinded) { + val offer = details.paymentRequest.invoiceRequest.offer + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_destination_label)) { + ContactOrOfferView(offer = offer) + } + } +} + +@Composable +private fun SplashFee(payment: LightningOutgoingPayment) { + val btcUnit = LocalBitcoinUnit.current + if (payment.state() == WalletPaymentState.SuccessOffChain) { + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_fees_label)) { + Text(text = payment.fees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) + } + } +} + +@Composable +private fun LnurlPayInfoView(payment: LightningOutgoingPayment, metadata: LnurlPayMetadata) { + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_lnurlpay_service)) { + SelectionContainer { + Text(text = metadata.pay.callback.host) + } + } + metadata.successAction?.let { + LnurlSuccessAction(payment = payment, action = it) + } +} + +@Composable +private fun LnurlSuccessAction(payment: LightningOutgoingPayment, action: LnurlPay.Invoice.SuccessAction) { + Spacer(modifier = Modifier.height(8.dp)) + when (action) { + is LnurlPay.Invoice.SuccessAction.Message -> { + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_lnurlpay_action_message_label)) { + SelectionContainer { + Text(text = action.message) + } + } + } + is LnurlPay.Invoice.SuccessAction.Url -> { + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_lnurlpay_action_url_label)) { + Text(text = action.description) + WebLink(text = stringResource(id = R.string.paymentdetails_lnurlpay_action_url_button), url = action.url.toString()) + } + } + is LnurlPay.Invoice.SuccessAction.Aes -> { + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_lnurlpay_action_aes_label)) { + val status = payment.status + if (status is LightningOutgoingPayment.Status.Completed.Succeeded.OffChain) { + val deciphered by produceState(initialValue = null) { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(status.preimage.toByteArray(), "AES"), IvParameterSpec(action.iv.toByteArray())) + value = String(cipher.doFinal(action.ciphertext.toByteArray()), Charsets.UTF_8) + } + Text(text = action.description) + when (deciphered) { + null -> ProgressView(text = stringResource(id = R.string.paymentdetails_lnurlpay_action_aes_decrypting), padding = PaddingValues(0.dp)) + else -> { + val url = try { + Url(deciphered!!) + } catch (e: Exception) { + null + } + if (url != null) { + WebLink(text = stringResource(id = R.string.paymentdetails_lnurlpay_action_url_button), url = url.toString()) + } else { + SelectionContainer { + Text(text = deciphered!!) + } + } + } + } + } else { + Text(text = stringResource(id = R.string.paymentdetails_lnurlpay_action_aes_decrypting)) + } + } + } + } +} + +@Composable +private fun PaymentErrorView(status: LightningOutgoingPayment.Status.Completed.Failed, failedParts: List) { + val failure = remember(status, failedParts) { OutgoingPaymentFailure(status.reason, failedParts) } + translatePaymentError(failure).let { + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_error_label)) { + Text(text = it) + } + } +} + +@Composable +fun translatePaymentError(paymentFailure: OutgoingPaymentFailure): String { + val context = LocalContext.current + val errorMessage = remember(key1 = paymentFailure) { + when (val result = paymentFailure.explain()) { + is Either.Left -> { + when (val partFailure = result.value) { + is LightningOutgoingPayment.Part.Status.Failure.Uninterpretable -> partFailure.message + LightningOutgoingPayment.Part.Status.Failure.ChannelIsClosing -> context.getString(R.string.outgoing_failuremessage_channel_closing) + LightningOutgoingPayment.Part.Status.Failure.ChannelIsSplicing -> context.getString(R.string.outgoing_failuremessage_channel_splicing) + LightningOutgoingPayment.Part.Status.Failure.NotEnoughFees -> context.getString(R.string.outgoing_failuremessage_not_enough_fee) + LightningOutgoingPayment.Part.Status.Failure.NotEnoughFunds -> context.getString(R.string.outgoing_failuremessage_not_enough_balance) + LightningOutgoingPayment.Part.Status.Failure.PaymentAmountTooBig -> context.getString(R.string.outgoing_failuremessage_too_big) + LightningOutgoingPayment.Part.Status.Failure.PaymentAmountTooSmall -> context.getString(R.string.outgoing_failuremessage_too_small) + LightningOutgoingPayment.Part.Status.Failure.PaymentExpiryTooBig -> context.getString(R.string.outgoing_failuremessage_expiry_too_big) + LightningOutgoingPayment.Part.Status.Failure.RecipientRejectedPayment -> context.getString(R.string.outgoing_failuremessage_rejected_by_recipient) + LightningOutgoingPayment.Part.Status.Failure.RecipientIsOffline -> context.getString(R.string.outgoing_failuremessage_recipient_offline) + LightningOutgoingPayment.Part.Status.Failure.RecipientLiquidityIssue -> context.getString(R.string.outgoing_failuremessage_not_enough_liquidity) + LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure -> context.getString(R.string.outgoing_failuremessage_temporary_failure) + LightningOutgoingPayment.Part.Status.Failure.TooManyPendingPayments -> context.getString(R.string.outgoing_failuremessage_too_many_pending) + } + } + is Either.Right -> { + when (result.value) { + FinalFailure.InvalidPaymentId -> context.getString(R.string.outgoing_failuremessage_invalid_id) + FinalFailure.AlreadyPaid -> context.getString(R.string.outgoing_failuremessage_alreadypaid) + FinalFailure.ChannelClosing -> context.getString(R.string.outgoing_failuremessage_channel_closing) + FinalFailure.ChannelNotConnected -> context.getString(R.string.outgoing_failuremessage_not_connected) + FinalFailure.ChannelOpening -> context.getString(R.string.outgoing_failuremessage_channel_opening) + FinalFailure.FeaturesNotSupported -> context.getString(R.string.outgoing_failuremessage_unsupported_features) + FinalFailure.InsufficientBalance -> context.getString(R.string.outgoing_failuremessage_not_enough_balance) + FinalFailure.InvalidPaymentAmount -> context.getString(R.string.outgoing_failuremessage_invalid_amount) + FinalFailure.NoAvailableChannels -> context.getString(R.string.outgoing_failuremessage_no_available_channels) + FinalFailure.RecipientUnreachable -> context.getString(R.string.outgoing_failuremessage_noroutefound) + FinalFailure.RetryExhausted -> context.getString(R.string.outgoing_failuremessage_noroutefound) + FinalFailure.UnknownError -> context.getString(R.string.outgoing_failuremessage_unknown) + FinalFailure.WalletRestarted -> context.getString(R.string.outgoing_failuremessage_restarted) + } + } + } + } + return errorMessage +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashLiquidityPurchase.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashLiquidityPurchase.kt new file mode 100644 index 000000000..c882b9a30 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashLiquidityPurchase.kt @@ -0,0 +1,267 @@ +/* + * 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.payments.details.splash + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment +import fr.acinq.lightning.wire.LiquidityAds +import fr.acinq.phoenix.android.LocalBitcoinUnit +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.Screen +import fr.acinq.phoenix.android.business +import fr.acinq.phoenix.android.components.BorderButton +import fr.acinq.phoenix.android.components.BottomSheetDialog +import fr.acinq.phoenix.android.components.Clickable +import fr.acinq.phoenix.android.components.SplashLabelRow +import fr.acinq.phoenix.android.components.TextWithIcon +import fr.acinq.phoenix.android.navController +import fr.acinq.phoenix.android.navigateToPaymentDetails +import fr.acinq.phoenix.android.payments.details.PaymentLine +import fr.acinq.phoenix.android.payments.details.PaymentLineLoading +import fr.acinq.phoenix.android.utils.Converter.toPrettyString +import fr.acinq.phoenix.android.utils.Converter.toRelativeDateString +import fr.acinq.phoenix.android.utils.MSatDisplayPolicy +import fr.acinq.phoenix.android.utils.annotatedStringResource +import fr.acinq.phoenix.android.utils.mutedTextColor +import fr.acinq.phoenix.android.utils.positiveColor +import fr.acinq.phoenix.data.WalletPaymentFetchOptions +import fr.acinq.phoenix.data.WalletPaymentId +import fr.acinq.phoenix.data.WalletPaymentInfo +import fr.acinq.phoenix.data.WalletPaymentMetadata + +@Composable +fun SplashLiquidityPurchase( + payment: InboundLiquidityOutgoingPayment, + metadata: WalletPaymentMetadata, + onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit, +) { + SplashPurchase(purchase = payment.purchase) + Spacer(modifier = Modifier.height(12.dp)) + SplashFee(payment = payment) + + // FIXME: dangerous!! + // In general, FromChannelBalance only happens for manual purchases OR automated swap-ins with additional liquidity. + // However, swap-ins do not **yet** request additional liquidity, so **for now** we can make a safe approximation. + // Eventually, once swap-ins are upgraded to request liquidity, this will have to be fixed, . + if (payment.purchase.paymentDetails !is LiquidityAds.PaymentDetails.FromChannelBalance) { + AutoLiquidityDetails(purchase = payment.purchase) + } +} + +@Composable +fun SplashLiquidityStatus(payment: InboundLiquidityOutgoingPayment, fromEvent: Boolean) { + when (val lockedAt = payment.lockedAt) { + null -> { + PaymentStatusIcon( + message = null, + imageResId = R.drawable.ic_payment_details_pending_onchain_static, + isAnimated = false, + color = mutedTextColor, + ) + } + else -> { + PaymentStatusIcon( + message = { + if (payment.purchase.paymentDetails is LiquidityAds.PaymentDetails.FromChannelBalance) { + Text(text = annotatedStringResource(id = R.string.paymentdetails_status_inbound_liquidity_success, lockedAt.toRelativeDateString())) + } else { + Text(text = annotatedStringResource(id = R.string.paymentdetails_status_inbound_liquidity_auto_success, lockedAt.toRelativeDateString())) + } + }, + imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, + isAnimated = fromEvent, + color = positiveColor, + ) + } + } +} + + +@Composable +private fun SplashFee( + payment: InboundLiquidityOutgoingPayment +) { + val btcUnit = LocalBitcoinUnit.current + SplashLabelRow( + label = stringResource(id = R.string.paymentdetails_liquidity_miner_fee_label), + helpMessage = stringResource(id = R.string.paymentdetails_liquidity_miner_fee_help) + ) { + Text(text = payment.miningFees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) + } + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow( + label = stringResource(id = R.string.paymentdetails_liquidity_service_fee_label), + helpMessage = stringResource(id = R.string.paymentdetails_liquidity_service_fee_help) + ) { + Text(text = payment.serviceFees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) + if (payment.purchase is LiquidityAds.Purchase.WithFeeCredit) { + Text(text = "Paid with fee credit") + } + } +} + +@Composable +private fun SplashPurchase( + purchase: LiquidityAds.Purchase +) { + val btcUnit = LocalBitcoinUnit.current + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = "Liquidity") { + Text(text = purchase.amount.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun AutoLiquidityDetails( + purchase: LiquidityAds.Purchase +) { + val navController = navController + var showPaymentsDialog by remember { mutableStateOf(false) } + + Spacer(modifier = Modifier.height(32.dp)) + BorderButton( + text = "What is this?", + icon = R.drawable.ic_help_circle, + onClick = { showPaymentsDialog = true }, + maxLines = 1, + ) + + if (showPaymentsDialog) { + BottomSheetDialog(onDismiss = { showPaymentsDialog = false }, modifier = Modifier.fillMaxHeight(.6f), internalPadding = PaddingValues(bottom = 32.dp)) { + val pagerState = rememberPagerState(pageCount = { 2 }) + HorizontalPager( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth(), + state = pagerState, + verticalAlignment = Alignment.Top, + beyondBoundsPageCount = 1 + ) { index -> + when (index) { + 0 -> { + Column { + Text( + text = "Why did this payment happen?", + style = MaterialTheme.typography.h4, + modifier = Modifier.padding(horizontal = 24.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = "Your Lightning channel had to be resized, which is an on-chain operation incurring fees.", modifier = Modifier.padding(horizontal = 24.dp)) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = "This operation was necessary to accommodate new incoming payments.", modifier = Modifier.padding(horizontal = 24.dp)) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Swipe right to see these payments.", + style = MaterialTheme.typography.caption.copy(fontSize = 14.sp), + modifier = Modifier.padding(horizontal = 24.dp) + ) + + Spacer(modifier = Modifier.height(32.dp)) + Text( + text = "How to optimise channels resizing?", + style = MaterialTheme.typography.h4, + modifier = Modifier.padding(horizontal = 24.dp), + ) + Spacer(modifier = Modifier.height(4.dp)) + Clickable(onClick = { navController.navigate(Screen.LiquidityPolicy.route) }, modifier = Modifier.padding(horizontal = 12.dp), shape = RoundedCornerShape(10.dp)) { + Column(modifier = Modifier + .fillMaxWidth() + .padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally) { + TextWithIcon( + text = "Configure automated management", + icon = R.drawable.ic_settings, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text(text = "Cap fees, or disable them altogether", style = MaterialTheme.typography.subtitle2) + } + } + Clickable(onClick = { navController.navigate(Screen.LiquidityRequest.route) }, modifier = Modifier.padding(horizontal = 12.dp), shape = RoundedCornerShape(10.dp)) { + Column(modifier = Modifier + .fillMaxWidth() + .padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally) { + TextWithIcon( + text = "Purchase liquidity in advance", + icon = R.drawable.ic_idea, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text(text = "Requires some planning, but is most optimal", style = MaterialTheme.typography.subtitle2) + } + } + } + } + 1 -> { + Column(modifier = Modifier.fillMaxSize()) { + Text(text = "Operation triggered by...", style = MaterialTheme.typography.h4, modifier = Modifier.padding(horizontal = 16.dp)) + Spacer(modifier = Modifier.height(8.dp)) + val paymentHashes = when (val details = purchase.paymentDetails) { + is LiquidityAds.PaymentDetails.FromFutureHtlc -> details.paymentHashes + is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> details.paymentHashes + else -> emptyList() + } + TriggeredBy(paymentHashes = paymentHashes) + } + } + } + } + } + } +} + +@Composable +private fun TriggeredBy(paymentHashes: List) { + val context = LocalContext.current + val navController = navController + val paymentsManager = business.paymentsManager + paymentHashes.forEach { paymentHash -> + val id = remember(paymentHash) { WalletPaymentId.IncomingPaymentId(paymentHash) } + val paymentInfo by produceState(initialValue = null) { + value = paymentsManager.getPayment(id = id, options = WalletPaymentFetchOptions.None) + } + + paymentInfo?.let { + PaymentLine(paymentInfo = it, contactInfo = null, onPaymentClick = { navigateToPaymentDetails(navController, id, isFromEvent = false) }) + } ?: PaymentLineLoading(paymentId = id, onPaymentClick = { navigateToPaymentDetails(navController, id, isFromEvent = false) }) + } +} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashSpliceOut.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashSpliceOut.kt new file mode 100644 index 000000000..8fd05bfb4 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashSpliceOut.kt @@ -0,0 +1,73 @@ +/* + * 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.payments.details.splash + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import fr.acinq.lightning.db.SpliceOutgoingPayment +import fr.acinq.phoenix.android.LocalBitcoinUnit +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.SplashLabelRow +import fr.acinq.phoenix.android.utils.Converter.toPrettyString +import fr.acinq.phoenix.android.utils.MSatDisplayPolicy +import fr.acinq.phoenix.android.utils.smartDescription +import fr.acinq.phoenix.data.WalletPaymentId +import fr.acinq.phoenix.data.WalletPaymentMetadata +import fr.acinq.phoenix.data.walletPaymentId + +@Composable +fun SplashSpliceOut( + payment: SpliceOutgoingPayment, + metadata: WalletPaymentMetadata, + onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit, +) { + val context = LocalContext.current + SplashDescription( + description = payment.smartDescription(context), + userDescription = metadata.userDescription, + paymentId = payment.walletPaymentId(), + onMetadataDescriptionUpdate = onMetadataDescriptionUpdate + ) + SplashDestination(payment) + SplashFee(payment = payment) +} + +@Composable +private fun SplashDestination(payment: SpliceOutgoingPayment) { + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_destination_label), icon = R.drawable.ic_chain) { + SelectionContainer { + Text(text = payment.address) + } + } +} + +@Composable +private fun SplashFee(payment: SpliceOutgoingPayment) { + val btcUnit = LocalBitcoinUnit.current + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_fees_label)) { + Text(text = payment.fees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) + } +} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashSpliceOutCpfp.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashSpliceOutCpfp.kt new file mode 100644 index 000000000..237047044 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashSpliceOutCpfp.kt @@ -0,0 +1,73 @@ +/* + * 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.payments.details.splash + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment +import fr.acinq.phoenix.android.LocalBitcoinUnit +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.SplashLabelRow +import fr.acinq.phoenix.android.utils.Converter.toPrettyString +import fr.acinq.phoenix.android.utils.MSatDisplayPolicy +import fr.acinq.phoenix.android.utils.smartDescription +import fr.acinq.phoenix.data.WalletPaymentId +import fr.acinq.phoenix.data.WalletPaymentMetadata +import fr.acinq.phoenix.data.walletPaymentId + +@Composable +fun SplashSpliceOutCpfp( + payment: SpliceCpfpOutgoingPayment, + metadata: WalletPaymentMetadata, + onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit, +) { + val context = LocalContext.current + SplashDescription( + description = payment.smartDescription(context), + userDescription = metadata.userDescription, + paymentId = payment.walletPaymentId(), + onMetadataDescriptionUpdate = onMetadataDescriptionUpdate + ) + SplashDestination() + SplashFee(payment = payment) +} + +@Composable +private fun SplashDestination() { + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_destination_label), icon = R.drawable.ic_chain) { + SelectionContainer { + Text(text = stringResource(id = R.string.paymentdetails_destination_cpfp_value)) + } + } +} + +@Composable +private fun SplashFee(payment: SpliceCpfpOutgoingPayment) { + val btcUnit = LocalBitcoinUnit.current + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_fees_label)) { + Text(text = payment.fees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) + } +} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityView.kt index b02dc3c1f..51599da92 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityView.kt @@ -237,7 +237,7 @@ private fun RequestLiquidityBottomSection( ErrorMessage(header = stringResource(id = R.string.validation_invalid_amount)) } else { ReviewLiquidityRequest( - onConfirm = { vm.requestInboundLiquidity(amount = state.amount, feerate = state.actualFeerate) } + onConfirm = { vm.requestInboundLiquidity(amount = state.amount, feerate = state.actualFeerate, fundingRate = state.fundingRate) } ) } } @@ -245,7 +245,7 @@ private fun RequestLiquidityBottomSection( ProgressView(text = stringResource(id = R.string.liquidityads_requesting_spinner)) } is RequestLiquidityState.Complete.Success -> { - LeaseSuccessDetails(liquidityDetails = state.response) + LiquiditySuccessDetails(liquidityDetails = state.response) } is RequestLiquidityState.Error.NoChannelsAvailable -> { ErrorMessage( @@ -253,6 +253,12 @@ private fun RequestLiquidityBottomSection( details = stringResource(id = R.string.liquidityads_error_channels_unavailable) ) } + is RequestLiquidityState.Error.InvalidFundingAmount -> { + ErrorMessage( + header = stringResource(id = R.string.liquidityads_error_header), + details = "Invalid amount requested. Please try again." + ) + } is RequestLiquidityState.Error.Thrown -> { ErrorMessage( header = stringResource(id = R.string.liquidityads_error_header), @@ -375,10 +381,10 @@ private fun ReviewLiquidityRequest( } @Composable -private fun LeaseSuccessDetails(liquidityDetails: ChannelCommand.Commitment.Splice.Response.Created) { +private fun LiquiditySuccessDetails(liquidityDetails: ChannelCommand.Commitment.Splice.Response.Created) { SuccessMessage( header = stringResource(id = R.string.liquidityads_success), - details = liquidityDetails.liquidityLease?.amount?.let { + details = liquidityDetails.liquidityPurchase?.amount?.let { stringResource(id = R.string.liquidityads_success_amount, it.toPrettyString(unit = LocalBitcoinUnit.current, withUnit = true)) }, alignment = Alignment.CenterHorizontally, diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityViewModel.kt index 175f83ef9..0cb7a4607 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityViewModel.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityViewModel.kt @@ -24,8 +24,8 @@ import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.ChannelCommand import fr.acinq.lightning.channel.ChannelManagementFees +import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.phoenix.managers.AppConfigurationManager -import fr.acinq.phoenix.managers.NodeParamsManager import fr.acinq.phoenix.managers.PeerManager import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers @@ -38,7 +38,7 @@ import org.slf4j.LoggerFactory sealed class RequestLiquidityState { object Init: RequestLiquidityState() object Estimating: RequestLiquidityState() - data class Estimation(val amount: Satoshi, val fees: ChannelManagementFees, val actualFeerate: FeeratePerKw): RequestLiquidityState() + data class Estimation(val amount: Satoshi, val fees: ChannelManagementFees, val actualFeerate: FeeratePerKw, val fundingRate: LiquidityAds.FundingRate): RequestLiquidityState() object Requesting: RequestLiquidityState() sealed class Complete: RequestLiquidityState() { abstract val response: ChannelCommand.Commitment.Splice.Response @@ -47,7 +47,8 @@ sealed class RequestLiquidityState { } sealed class Error: RequestLiquidityState() { data class Thrown(val cause: Throwable): Error() - object NoChannelsAvailable: Error() + data object NoChannelsAvailable: Error() + data object InvalidFundingAmount: Error() } } @@ -65,23 +66,29 @@ class RequestLiquidityViewModel(val peerManager: PeerManager, val appConfigManag }) { val peer = peerManager.getPeer() val feerate = appConfigManager.mempoolFeerate.filterNotNull().first().hour + val fundingRate = peer.remoteFundingRates.filterNotNull().first().findRate(amount) + if (fundingRate == null) { + state.value = RequestLiquidityState.Error.InvalidFundingAmount + return@launch + } + peer.estimateFeeForInboundLiquidity( amount = amount, targetFeerate = FeeratePerKw(feerate), - leaseRate = NodeParamsManager.liquidityLeaseRate(amount), + fundingRate = fundingRate, ).let { response -> state.value = when (response) { null -> RequestLiquidityState.Error.NoChannelsAvailable else -> { val (actualFeerate, fees) = response - RequestLiquidityState.Estimation(amount, fees, actualFeerate) + RequestLiquidityState.Estimation(amount, fees, actualFeerate, fundingRate) } } } } } - fun requestInboundLiquidity(amount: Satoshi, feerate: FeeratePerKw) { + fun requestInboundLiquidity(amount: Satoshi, feerate: FeeratePerKw, fundingRate: LiquidityAds.FundingRate) { if (state.value is RequestLiquidityState.Requesting) return state.value = RequestLiquidityState.Requesting viewModelScope.launch(Dispatchers.Default + CoroutineExceptionHandler { _, e -> @@ -92,7 +99,7 @@ class RequestLiquidityViewModel(val peerManager: PeerManager, val appConfigManag peer.requestInboundLiquidity( amount = amount, feerate = feerate, - leaseRate = NodeParamsManager.liquidityLeaseRate(amount), + fundingRate = fundingRate, ).let { response -> state.value = when (response) { null -> RequestLiquidityState.Error.NoChannelsAvailable diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/offer/SendOfferView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/offer/SendOfferView.kt index 25ea4a60b..e722dafdc 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/offer/SendOfferView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/offer/SendOfferView.kt @@ -64,7 +64,7 @@ import fr.acinq.phoenix.android.components.SplashLayout import fr.acinq.phoenix.android.components.TextInput import fr.acinq.phoenix.android.components.contact.ContactOrOfferView import fr.acinq.phoenix.android.components.feedback.ErrorMessage -import fr.acinq.phoenix.android.payments.details.translatePaymentError +import fr.acinq.phoenix.android.payments.details.splash.translatePaymentError import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.Converter.toPrettyString diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt index 9795b4986..e75bb913b 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt @@ -21,7 +21,7 @@ import com.google.firebase.messaging.FirebaseMessaging import fr.acinq.bitcoin.TxId import fr.acinq.lightning.LiquidityEvents import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.io.PaymentReceived +import fr.acinq.lightning.PaymentEvents import fr.acinq.lightning.utils.Connection import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.phoenix.PhoenixBusiness @@ -257,7 +257,7 @@ class NodeService : Service() { val trustedSwapInTxs = LegacyPrefsDatastore.getMigrationTrustedSwapInTxs(applicationContext).first() val preferredFiatCurrency = userPrefs.getFiatCurrency.first() - monitorPaymentsJob = serviceScope.launch { monitorPaymentsWhenHeadless(business.peerManager, business.currencyManager, userPrefs) } + monitorPaymentsJob = serviceScope.launch { monitorPaymentsWhenHeadless(business.nodeParamsManager, business.currencyManager, userPrefs) } monitorNodeEventsJob = serviceScope.launch { monitorNodeEvents(business.peerManager, business.nodeParamsManager) } monitorFcmTokenJob = serviceScope.launch { monitorFcmToken(business) } monitorInFlightPaymentsJob = serviceScope.launch { monitorInFlightPayments(business.peerManager) } @@ -327,8 +327,12 @@ class NodeService : Service() { is LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee -> { SystemNotificationHelper.notifyPaymentRejectedOverRelative(applicationContext, event.source, event.amount, event.fee, reason.maxRelativeFeeBasisPoints, nextTimeout?.second) } - LiquidityEvents.Rejected.Reason.ChannelInitializing -> { - SystemNotificationHelper.notifyPaymentRejectedChannelsInitializing(applicationContext, event.source, event.amount, nextTimeout?.second) + // Temporary errors + is LiquidityEvents.Rejected.Reason.ChannelFundingInProgress, + is LiquidityEvents.Rejected.Reason.MissingOffChainAmountTooLow, + is LiquidityEvents.Rejected.Reason.NoMatchingFundingRate, + is LiquidityEvents.Rejected.Reason.TooManyParts -> { + SystemNotificationHelper.notifyPaymentRejectedFundingError(applicationContext, event.source, event.amount) } } } @@ -337,17 +341,18 @@ class NodeService : Service() { } } - private suspend fun monitorPaymentsWhenHeadless(peerManager: PeerManager, currencyManager: CurrencyManager, userPrefs: UserPrefsRepository) { - peerManager.getPeer().eventsFlow.collect { event -> + private suspend fun monitorPaymentsWhenHeadless(nodeParamsManager: NodeParamsManager, currencyManager: CurrencyManager, userPrefs: UserPrefsRepository) { + + nodeParamsManager.nodeParams.filterNotNull().first().nodeEvents.collect { event -> when (event) { - is PaymentReceived -> { + is PaymentEvents.PaymentReceived -> { if (isHeadless) { - receivedInBackground.add(event.received.amount) + receivedInBackground.add(event.amount) SystemNotificationHelper.notifyPaymentsReceived( context = applicationContext, userPrefs = userPrefs, - paymentHash = event.incomingPayment.paymentHash, - amount = event.received.amount, + paymentHash = event.paymentHash, + amount = event.amount, rates = currencyManager.ratesFlow.value, isHeadless = isHeadless && receivedInBackground.size == 1 ) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/NotificationsView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/NotificationsView.kt index ee7b80484..25004ed6b 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/NotificationsView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/NotificationsView.kt @@ -268,14 +268,14 @@ private fun PaymentNotification( notification.fee.toPrettyString(btcUnit, withUnit = true), notification.maxAbsoluteFee.toPrettyString(btcUnit, withUnit = true), ) - is Notification.OverRelativeFee -> stringResource( id = R.string.inappnotif_payment_rejected_over_relative, notification.fee.toPrettyString(btcUnit, withUnit = true), DecimalFormat("0.##").format(notification.maxRelativeFeeBasisPoints.toDouble() / 100), ) - - is Notification.ChannelsInitializing -> stringResource(id = R.string.inappnotif_payment_rejected_channel_initializing) + is Notification.GenericError -> "An error has occurred. Please try again." + is Notification.ChannelFundingInProgress -> "A funding is in progress. Try again later." + is Notification.MissingOffChainAmountTooLow -> "The amount is too low." }, bottomText = when (notification) { is Notification.OverAbsoluteFee, is Notification.OverRelativeFee, is Notification.FeePolicyDisabled -> { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/AdvancedIncomingFeePolicy.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/AdvancedIncomingFeePolicy.kt index 9aea96eef..bab90ef09 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/AdvancedIncomingFeePolicy.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/AdvancedIncomingFeePolicy.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import fr.acinq.lightning.payment.LiquidityPolicy +import fr.acinq.lightning.utils.msat import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.business import fr.acinq.phoenix.android.components.Button @@ -111,7 +112,15 @@ fun AdvancedIncomingFeePolicy( } Card { - val newPolicy = maxRelativeFeeBasisPoints?.let { LiquidityPolicy.Auto(maxRelativeFeeBasisPoints = it, maxAbsoluteFee = maxAbsoluteFee, skipAbsoluteFeeCheck = skipAbsoluteFeeCheck) } + val newPolicy = maxRelativeFeeBasisPoints?.let { + LiquidityPolicy.Auto( + inboundLiquidityTarget = null, + maxRelativeFeeBasisPoints = it, + maxAbsoluteFee = maxAbsoluteFee, + skipAbsoluteFeeCheck = skipAbsoluteFeeCheck, + maxAllowedFeeCredit = 0.msat, + ) + } val isEnabled = newPolicy != null && liquidityPolicyPrefs != newPolicy Button( text = stringResource(id = R.string.liquiditypolicy_save_button), diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/LiquidityPolicyView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/LiquidityPolicyView.kt index 302d0086a..c74b511c4 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/LiquidityPolicyView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/LiquidityPolicyView.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.payment.LiquidityPolicy +import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat import fr.acinq.phoenix.android.LocalFiatCurrency import fr.acinq.phoenix.android.R @@ -132,7 +133,15 @@ fun LiquidityPolicyView( val skipAbsoluteFeeCheck = if (liquidityPolicyPrefs is LiquidityPolicy.Auto) liquidityPolicyPrefs.skipAbsoluteFeeCheck else false val newPolicy = when { isPolicyDisabled -> LiquidityPolicy.Disable - else -> maxAbsoluteFee?.let { LiquidityPolicy.Auto(maxRelativeFeeBasisPoints = maxPropFeePrefs, maxAbsoluteFee = it, skipAbsoluteFeeCheck = skipAbsoluteFeeCheck) } + else -> maxAbsoluteFee?.let { + LiquidityPolicy.Auto( + inboundLiquidityTarget = null, + maxRelativeFeeBasisPoints = maxPropFeePrefs, + maxAbsoluteFee = it, + skipAbsoluteFeeCheck = skipAbsoluteFeeCheck, + maxAllowedFeeCredit = 0.msat, + ) + } } val isEnabled = newPolicy != null && liquidityPolicyPrefs != newPolicy Button( diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInWalletInfo.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInWalletInfo.kt index e7c59b83c..f910b2188 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInWalletInfo.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInWalletInfo.kt @@ -237,7 +237,9 @@ private fun ReadyForSwapView( DecimalFormat("0.##").format(lastSwapFailedNotification.maxRelativeFeeBasisPoints.toDouble() / 100), ) is Notification.FeePolicyDisabled -> stringResource(id = R.string.walletinfo_onchain_swapin_last_attempt_disabled) - is Notification.ChannelsInitializing -> stringResource(id = R.string.walletinfo_onchain_swapin_last_attempt_channels_init) + is Notification.ChannelFundingInProgress -> stringResource(id = R.string.walletinfo_onchain_swapin_last_attempt_funding_in_progress) + is Notification.MissingOffChainAmountTooLow -> stringResource(id = R.string.walletinfo_onchain_swapin_last_attempt_amount_too_low) + is Notification.GenericError -> stringResource(id = R.string.walletinfo_onchain_swapin_last_attempt_generic) }, ) } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/LegacyMigrationHelper.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/LegacyMigrationHelper.kt index b271c424c..82ebe8cea 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/LegacyMigrationHelper.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/LegacyMigrationHelper.kt @@ -46,6 +46,7 @@ import fr.acinq.phoenix.data.BitcoinUnit import fr.acinq.phoenix.data.FiatCurrency import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.data.WalletPaymentInfo +import fr.acinq.phoenix.data.WalletPaymentMetadata import fr.acinq.phoenix.data.lnurl.LnurlAuth import fr.acinq.phoenix.legacy.db.* import fr.acinq.phoenix.legacy.utils.Prefs @@ -305,7 +306,7 @@ object LegacyMigrationHelper { // use the PayToOpen metadata to know how the payment was received val receivedWith = if (payToOpenMeta != null || payment.paymentType() == PaymentType.SwapIn()) { IncomingPayment.ReceivedWith.NewChannel( - amount = status.amount().toLong().msat, + amountReceived = status.amount().toLong().msat, serviceFee = payToOpenMeta?.fee_sat?.sat?.toMilliSatoshi() ?: 0.msat, miningFee = 0.sat, channelId = ByteVector32.Zeroes, @@ -315,9 +316,10 @@ object LegacyMigrationHelper { ) } else { IncomingPayment.ReceivedWith.LightningPayment( - amount = status.amount().toLong().msat, + amountReceived = status.amount().toLong().msat, channelId = ByteVector32.Zeroes, - htlcId = 0L + htlcId = 0L, + fundingFee = null, ) } @@ -494,12 +496,11 @@ object LegacyMigrationHelper { } /** Returns true if the payment is a channel-close made by the legacy app to the node's swap-in address. Uses the [LegacyMigrationHelper.migrationDescFlag] metadata flag. */ -fun WalletPaymentInfo.isLegacyMigration(peer: Peer?): Boolean? { - val p = payment +fun WalletPayment.isLegacyMigration(metadata: WalletPaymentMetadata, peer: Peer?): Boolean? { return when { - p !is ChannelCloseOutgoingPayment -> false + this !is ChannelCloseOutgoingPayment -> false peer == null -> null - p.address == peer.phoenixSwapInWallet.legacySwapInAddress && metadata.userDescription == LegacyMigrationHelper.migrationDescFlag -> true + this.address == peer.phoenixSwapInWallet.legacySwapInAddress && metadata.userDescription == LegacyMigrationHelper.migrationDescFlag -> true else -> false } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/SystemNotificationHelper.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/SystemNotificationHelper.kt index c4bd04a40..e59388a0e 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/SystemNotificationHelper.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/SystemNotificationHelper.kt @@ -151,12 +151,12 @@ object SystemNotificationHelper { ) } - fun notifyPaymentRejectedChannelsInitializing(context: Context, source: LiquidityEvents.Source, amountIncoming: MilliSatoshi, nextTimeoutRemainingBlocks: Int?): Notification { + fun notifyPaymentRejectedFundingError(context: Context, 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_channels_initializing), + message = context.getString(R.string.notif_rejected_generic_error), deepLink = if (source == LiquidityEvents.Source.OnChainWallet) "phoenix:swapinwallet" else "phoenix:liquiditypolicy", ) } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefsRepository.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefsRepository.kt index 842436df3..e82e737d3 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefsRepository.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefsRepository.kt @@ -25,6 +25,7 @@ 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.android.utils.UserTheme import fr.acinq.phoenix.data.BitcoinUnit @@ -214,7 +215,13 @@ class UserPrefsRepository(private val data: DataStore) { try { it[LIQUIDITY_POLICY]?.let { policy -> when (val res = json.decodeFromString(policy)) { - is InternalLiquidityPolicy.Auto -> LiquidityPolicy.Auto(res.maxAbsoluteFee, res.maxRelativeFeeBasisPoints, res.skipAbsoluteFeeCheck) + is InternalLiquidityPolicy.Auto -> LiquidityPolicy.Auto( + inboundLiquidityTarget = null, + maxAbsoluteFee = res.maxAbsoluteFee, + maxRelativeFeeBasisPoints = res.maxRelativeFeeBasisPoints, + skipAbsoluteFeeCheck = res.skipAbsoluteFeeCheck, + maxAllowedFeeCredit = 0.msat, + ) is InternalLiquidityPolicy.Disable -> LiquidityPolicy.Disable } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions.kt index d5ea31466..ca81a3e57 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions.kt @@ -17,22 +17,18 @@ package fr.acinq.phoenix.android.utils import android.content.* -import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.core.content.FileProvider import fr.acinq.lightning.db.* import fr.acinq.lightning.utils.Connection -import fr.acinq.lightning.utils.currentTimestampMillis +import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.phoenix.android.* import fr.acinq.phoenix.android.R -import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.data.BitcoinUnit import fr.acinq.phoenix.data.FiatCurrency import fr.acinq.phoenix.utils.extensions.desc -import java.io.File import java.security.cert.CertificateException import java.util.* import kotlin.contracts.ExperimentalContracts @@ -126,6 +122,27 @@ fun UserTheme.label(): String { fun Connection.CLOSED.isBadCertificate() = this.reason?.cause is CertificateException +fun LightningOutgoingPayment.smartDescription(context: Context): String? = when (val details = this.details) { + is LightningOutgoingPayment.Details.Normal -> details.paymentRequest.desc + is LightningOutgoingPayment.Details.SwapOut -> context.getString(R.string.paymentdetails_desc_swapout, details.address) + is LightningOutgoingPayment.Details.Blinded -> details.paymentRequest.description +}?.takeIf { it.isNotBlank() } +fun SpliceOutgoingPayment.smartDescription(context: Context): String = context.getString(R.string.paymentdetails_desc_splice_out) +fun SpliceCpfpOutgoingPayment.smartDescription(context: Context): String = context.getString(R.string.paymentdetails_desc_cpfp) +fun ChannelCloseOutgoingPayment.smartDescription(context: Context): String = context.getString(R.string.paymentdetails_desc_closing_channel) +fun InboundLiquidityOutgoingPayment.smartDescription(context: Context): String = when (purchase.paymentDetails) { + // manual inbound liquidity + LiquidityAds.PaymentDetails.FromChannelBalance -> "Manual liquidity" // context.getString(R.string.paymentdetails_desc_inbound_liquidity, purchase.amount.toPrettyString(BitcoinUnit.Sat, withUnit = true)) + // pay-to-open/pay-to-splice + else -> "Automated liquidity" +} + +fun IncomingPayment.smartDescription(context: Context) : String? = when (val origin = this.origin) { + is IncomingPayment.Origin.Invoice -> origin.paymentRequest.description + is IncomingPayment.Origin.SwapIn, is IncomingPayment.Origin.OnChain -> context.getString(R.string.paymentdetails_desc_swapin) + is IncomingPayment.Origin.Offer -> null +}?.takeIf { it.isNotBlank() } + /** * Returns a trimmed, localized description of the payment, based on the type and information available. May be null! * @@ -133,18 +150,10 @@ fun Connection.CLOSED.isBadCertificate() = this.reason?.cause is CertificateExce * payment with an invoice do have a description baked in, and that's what is returned. */ fun WalletPayment.smartDescription(context: Context): String? = when (this) { - is LightningOutgoingPayment -> when (val details = this.details) { - is LightningOutgoingPayment.Details.Normal -> details.paymentRequest.desc - is LightningOutgoingPayment.Details.SwapOut -> context.getString(R.string.paymentdetails_desc_swapout, details.address) - is LightningOutgoingPayment.Details.Blinded -> details.paymentRequest.description - } - is IncomingPayment -> when (val origin = this.origin) { - is IncomingPayment.Origin.Invoice -> origin.paymentRequest.description - is IncomingPayment.Origin.SwapIn, is IncomingPayment.Origin.OnChain -> context.getString(R.string.paymentdetails_desc_swapin) - is IncomingPayment.Origin.Offer -> null - } - is SpliceOutgoingPayment -> context.getString(R.string.paymentdetails_desc_splice_out) - is ChannelCloseOutgoingPayment -> context.getString(R.string.paymentdetails_desc_closing_channel) - is SpliceCpfpOutgoingPayment -> context.getString(R.string.paymentdetails_desc_cpfp) - is InboundLiquidityOutgoingPayment -> context.getString(R.string.paymentdetails_desc_inbound_liquidity, lease.amount.toPrettyString(BitcoinUnit.Sat, withUnit = true)) -}?.takeIf { it.isNotBlank() } \ No newline at end of file + is LightningOutgoingPayment -> smartDescription(context) + is IncomingPayment -> smartDescription(context) + is SpliceOutgoingPayment -> smartDescription(context) + is ChannelCloseOutgoingPayment -> smartDescription(context) + is SpliceCpfpOutgoingPayment -> smartDescription(context) + is InboundLiquidityOutgoingPayment -> smartDescription(context) +} \ No newline at end of file diff --git a/phoenix-android/src/main/res/values-b+es+419/important_strings.xml b/phoenix-android/src/main/res/values-b+es+419/important_strings.xml index 5e546b468..1e58b9b5c 100644 --- a/phoenix-android/src/main/res/values-b+es+419/important_strings.xml +++ b/phoenix-android/src/main/res/values-b+es+419/important_strings.xml @@ -41,7 +41,6 @@ La comisión fue %1$s, pero el límite máximo se fijó en %2$s. Este depósito vencerá el %3$s. La comisión fue %1$s, que es más del %2$s%% del importe recibido. Toca para obtener más información. La comisión fue %1$s, que es más del %2$s%% del importe recibido. Este depósito vencerá el %3$s. - Los canales se estaban inicializando, por lo que no se pudo aceptar el pago. Intenta de nuevo más tarde. Inicia Phoenix Es posible que algunos de los canales se hayan cerrado. diff --git a/phoenix-android/src/main/res/values-b+es+419/strings.xml b/phoenix-android/src/main/res/values-b+es+419/strings.xml index f737205ab..cfcaeb56b 100644 --- a/phoenix-android/src/main/res/values-b+es+419/strings.xml +++ b/phoenix-android/src/main/res/values-b+es+419/strings.xml @@ -330,7 +330,7 @@ Mensaje Desencriptando mensaje… - Desc. + Descripción Enviado a Mineros de Bitcoin Comisiones diff --git a/phoenix-android/src/main/res/values-cs/important_strings.xml b/phoenix-android/src/main/res/values-cs/important_strings.xml index 9f4a465c7..11856103e 100644 --- a/phoenix-android/src/main/res/values-cs/important_strings.xml +++ b/phoenix-android/src/main/res/values-cs/important_strings.xml @@ -44,7 +44,6 @@ Poplatek činil %1$s, ale váš maximální poplatek byl nastaven na %2$s. Platnost této zálohy vyprší %3$s. Poplatek činil %1$s, což je více než %2$s%% z přijaté částky. Můžete to upravit v nastavení. Poplatek činil %1$s, což je více než %2$s%% z přijaté částky. Platnost této zálohy vyprší %3$s. - Vaše kanály se inicializovaly a nemohly tuto platbu přijmout. Zkuste to později. Spusťte prosím Phoenix Některý z vašich kanálů mohl být uzavřen. diff --git a/phoenix-android/src/main/res/values-de/important_strings.xml b/phoenix-android/src/main/res/values-de/important_strings.xml index 08c413c06..40c28e957 100644 --- a/phoenix-android/src/main/res/values-de/important_strings.xml +++ b/phoenix-android/src/main/res/values-de/important_strings.xml @@ -41,7 +41,6 @@ Die Gebühr wäre %1$s, aber Ihr Gebührenlimit beträgt %2$s. Diese Einzahlung verfällt am %3$s. Die Gebühr wäre %1$s, was mehr als %2$s%% des zu empfangenden Betrags ist. Sie können dies in den Einstellungen anpassen. Die Gebühr wäre %1$s, was mehr als %2$s%% des zu empfangenden Betrags ist. Diese Einzahlung verfällt am %3$s. - Ihre Kanäle werden gerade initialisiert und konnten die Zahlung nicht empfangen. Versuchen Sie es später noch mal. Bitte öffnen Sie Phoenix Einige Ihrer Kanäle wurden möglicherweise geschlossen. diff --git a/phoenix-android/src/main/res/values-de/strings.xml b/phoenix-android/src/main/res/values-de/strings.xml index 55efb98da..ee56cb041 100644 --- a/phoenix-android/src/main/res/values-de/strings.xml +++ b/phoenix-android/src/main/res/values-de/strings.xml @@ -337,7 +337,7 @@ Nachricht Nachricht entschlüsseln.. - Desc. + Beschreibung Gesendet an Bitcoin-Miner Gebühren @@ -382,7 +382,6 @@ - #%1$s: Geforderter Betrag - Unterschrift Schließungs-Typ Einvernehmlich diff --git a/phoenix-android/src/main/res/values-es/important_strings.xml b/phoenix-android/src/main/res/values-es/important_strings.xml index b77e6643a..4f9c70115 100644 --- a/phoenix-android/src/main/res/values-es/important_strings.xml +++ b/phoenix-android/src/main/res/values-es/important_strings.xml @@ -44,7 +44,6 @@ La tasa era %1$s, pero tu tarifa máxima estaba fijada en %2$s. Este depósito expirará el %3$s. La tasa fue de %1$s, que es más del %2$s%% del importe recibido. Puedes modificarlo en la configuración. La tasa fue de %1$s, que es más del %2$s%% del importe recibido. Este depósito expirará el %3$s. - Sus canales se estaban inicializando y no pudieron aceptar ese pago. Vuelva a intentarlo más tarde. Por favor, inicie Phoenix Es posible que algunos de sus canales hayan cerrado. diff --git a/phoenix-android/src/main/res/values-fr/important_strings.xml b/phoenix-android/src/main/res/values-fr/important_strings.xml index 52c827bbe..3ee5a3cac 100644 --- a/phoenix-android/src/main/res/values-fr/important_strings.xml +++ b/phoenix-android/src/main/res/values-fr/important_strings.xml @@ -44,7 +44,6 @@ Les frais étaient de %1$s, mais votre max est de %2$s. Le dépôt expirera le %3$s. Les frais étaient de %1$s et dépassent %2$s%% du montant. Cette configuration peut être changée. Les frais étaient de %1$s et dépassent %2$s%% du montant. Le dépôt expirera le %3$s. - Vos canaux de paiement étaient en initialisation, et n\'ont pu accepter ce paiement. Veuillez démarrer Phoenix Certains de vos canaux pourraient avoir fermé. diff --git a/phoenix-android/src/main/res/values-fr/strings.xml b/phoenix-android/src/main/res/values-fr/strings.xml index 9856c974b..6e9f52a50 100644 --- a/phoenix-android/src/main/res/values-fr/strings.xml +++ b/phoenix-android/src/main/res/values-fr/strings.xml @@ -380,7 +380,6 @@ - #%1$s: Liquidité demandée - Signature Type de clôture Mutuelle diff --git a/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml b/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml index 75c5c5ed4..379065e28 100644 --- a/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml +++ b/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml @@ -44,7 +44,6 @@ A taxa foi de %1$s, mas sua taxa máxima foi definida como %2$s. Esse depósito expirará em %3$s. A taxa foi de %1$s, que é mais do que %2$s%% do valor recebido. Você pode ajustar isso nas configurações. A taxa foi de %1$s, que é mais do que %2$s%% do valor recebido. Esse depósito expirará em %3$s. - Seus canais estavam sendo inicializados e não puderam aceitar esse pagamento. Tente novamente mais tarde. Por favor, inicie o Phoenix Alguns de seus canais podem ter sido fechados. diff --git a/phoenix-android/src/main/res/values-sk/important_strings.xml b/phoenix-android/src/main/res/values-sk/important_strings.xml index 0b61efaa3..53497fc86 100644 --- a/phoenix-android/src/main/res/values-sk/important_strings.xml +++ b/phoenix-android/src/main/res/values-sk/important_strings.xml @@ -44,7 +44,6 @@ Poplatok bol %1$s, ale váš maximálny poplatok bol nastavený na %2$s. Platnosť tohto vkladu vyprší %3$s. Poplatok bol %1$s, čo je viac než %2$s%% z prijatej sumy. Kliknite pre podrobnosti. Poplatok bol %1$s, čo je viac než %2$s%% z prijatej sumy. Platnosť tohto vkladu vyprší %3$s. - Vaše kanály sa inicializovali a nemohli prijať túto platbu. Skúste to neskôr. Spustite prosím Phoenix Niektorý z vašich kanálov mohol byť uzavretý. diff --git a/phoenix-android/src/main/res/values-sk/strings.xml b/phoenix-android/src/main/res/values-sk/strings.xml index 4c65ccc00..98502402c 100644 --- a/phoenix-android/src/main/res/values-sk/strings.xml +++ b/phoenix-android/src/main/res/values-sk/strings.xml @@ -404,7 +404,6 @@ - #%1$s: Požadovaná suma - Podpis Typ uzavretia Vzájomné diff --git a/phoenix-android/src/main/res/values-sw/important_strings.xml b/phoenix-android/src/main/res/values-sw/important_strings.xml index 16b45bbdc..72d18613a 100644 --- a/phoenix-android/src/main/res/values-sw/important_strings.xml +++ b/phoenix-android/src/main/res/values-sw/important_strings.xml @@ -48,7 +48,6 @@ Ada ilikuwa %1$s, lakini ada yako ya juu ilikuwa imewekwa kwa %2$s. Amana hii itaisha muda wake ifikapo %3$s. Ada ilikuwa %1$s ambayo ni zaidi ya %2$s%% ya kiasi kilichopokelewa. Bonyeza kwa maelezo zaidi. Ada ilikuwa %1$s ambayo ni zaidi ya %2$s%% ya kiasi kilichopokelewa. Amana hii itaisha muda wake ifikapo %3$s. - Chaneli zako zilikuwa zinaanzishwa na hazikuweza kukubali malipo hayo. Jaribu tena baadaye. Tafadhali anzisha Phoenix Baadhi ya chaneli zako zinaweza kuwa zimefungwa. diff --git a/phoenix-android/src/main/res/values-sw/strings.xml b/phoenix-android/src/main/res/values-sw/strings.xml index 8fdbcbc72..70b4890c2 100644 --- a/phoenix-android/src/main/res/values-sw/strings.xml +++ b/phoenix-android/src/main/res/values-sw/strings.xml @@ -423,7 +423,6 @@ - #%1$s: Kiasi kilichoombwa - Sahihi Aina ya kufunga Kushirikiana diff --git a/phoenix-android/src/main/res/values-vi/important_strings.xml b/phoenix-android/src/main/res/values-vi/important_strings.xml index a8a7d5224..b5a0c5784 100644 --- a/phoenix-android/src/main/res/values-vi/important_strings.xml +++ b/phoenix-android/src/main/res/values-vi/important_strings.xml @@ -51,7 +51,6 @@ Khoản phí là %1$s, nhưng phí tối đa của bạn được đặt là %2$s. Khoản tiền cọc này sẽ hết hạn vào %3$s. Khoản phí là %1$s và cao hơn %2$s%% so với khoản nhận được. Nhấn để biết thêm chi tiết. Khoản phí là %1$s và cao hơn %2$s%% so với khoản tiền sẽ nhận được. Khoản tiền này sẽ hết hạn vào %3$s. - Kênh của bạn đang được khởi tạo và không thể nhận khoản thanh toán này. Vui lòng thử lại sau. Xin hãy khởi động Phoenix. Một vài kênh của bạn có thể đã đóng. diff --git a/phoenix-android/src/main/res/values-vi/strings.xml b/phoenix-android/src/main/res/values-vi/strings.xml index cbd184fc9..36a841419 100644 --- a/phoenix-android/src/main/res/values-vi/strings.xml +++ b/phoenix-android/src/main/res/values-vi/strings.xml @@ -383,7 +383,6 @@ - #%1$s: Số tiền yêu cầu - Chữ ký Hình thức đóng Đồng thuận diff --git a/phoenix-android/src/main/res/values/important_strings.xml b/phoenix-android/src/main/res/values/important_strings.xml index a67f3efc9..253a0e171 100644 --- a/phoenix-android/src/main/res/values/important_strings.xml +++ b/phoenix-android/src/main/res/values/important_strings.xml @@ -48,7 +48,7 @@ The fee was %1$s, but your max fee was set to %2$s. This deposit will expire by %3$s. The fee was %1$s which is more than %2$s%% of the amount received. Tap for details. The fee was %1$s which is more than %2$s%% of the amount received. This deposit will expire by %3$s. - Your channels were initializing and could not accept that payment. Try again later. + An error occurred during the funding. Please try again later. Please start Phoenix Some of your channels may have closed. @@ -313,7 +313,9 @@ A swap attempt failed %1$s Channels management was disabled. - Channels were still initializing. + A funding is in progress. + The amount was too low. + An error has occurred. Try again later. This swap will expire in a day! This swap will expire in %1$s days. diff --git a/phoenix-android/src/main/res/values/strings.xml b/phoenix-android/src/main/res/values/strings.xml index 8b4251ed2..697f827c6 100644 --- a/phoenix-android/src/main/res/values/strings.xml +++ b/phoenix-android/src/main/res/values/strings.xml @@ -349,6 +349,7 @@ Could not find payment details LIQUIDITY ADDED %1$s + CHANNEL RESIZED %1$s COMPLETE %1$s SENT %1$s Pending… @@ -378,7 +379,7 @@ Message Decrypting message… - Desc. + Description Sent to Bitcoin miners Fees @@ -425,7 +426,6 @@ - #%1$s: Amount requested - Signature Closing type Mutual @@ -459,6 +459,7 @@ Splice-in (adding to existing channel) New channel (automatically created) Lightning payment + Fee credit Channel id Transaction @@ -470,6 +471,8 @@ Amount requested Amount sent (fees included) Amount received + Fee credit accrued + Amount added to fee credit ≈ %1$s (now) ≈ %1$s (then)