From 7f656e020df06ac263c86a657ba86e962fc25d14 Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Tue, 12 Dec 2023 11:43:14 +0100 Subject: [PATCH 01/10] Use lightning-kmp development version --- buildSrc/src/main/kotlin/Versions.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 957cfda9f..acc6c529d 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -1,5 +1,5 @@ object Versions { - const val lightningKmp = "1.5.12" + const val lightningKmp = "1.5.13-SNAPSHOT" const val secp256k1 = "0.11.0" const val torMobile = "0.2.0" From ca35d685d919873b59647407bf4b165b1837c9e9 Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Tue, 12 Dec 2023 11:44:02 +0100 Subject: [PATCH 02/10] (android) Dismiss notifications when changing advanced liquidity --- .../phoenix/android/settings/fees/AdvancedIncomingFeePolicy.kt | 2 ++ .../fr/acinq/phoenix/android/utils/datastore/UserPrefs.kt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) 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 165d893b9..79982e1ca 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 @@ -64,6 +64,7 @@ fun AdvancedIncomingFeePolicy( val context = LocalContext.current val scope = rememberCoroutineScope() val peerManager = business.peerManager + val notificationsManager = business.notificationsManager val maxSatFeePrefsFlow = UserPrefs.getIncomingMaxSatFeeInternal(context).collectAsState(null) val maxPropFeePrefsFlow = UserPrefs.getIncomingMaxPropFeeInternal(context).collectAsState(null) @@ -129,6 +130,7 @@ fun AdvancedIncomingFeePolicy( newPolicy?.let { UserPrefs.saveLiquidityPolicy(context, newPolicy) peerManager.updatePeerLiquidityPolicy(newPolicy) + notificationsManager.dismissAllNotifications() } } }, diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefs.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefs.kt index 3b76f5b3d..a18d4e28f 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefs.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefs.kt @@ -189,7 +189,7 @@ object UserPrefs { } suspend fun saveLiquidityPolicy(context: Context, policy: LiquidityPolicy) = context.userPrefs.edit { - log.debug("saving new liquidity policy=$policy") + log.info("saving new liquidity policy=$policy") val serialisable = when (policy) { is LiquidityPolicy.Auto -> InternalLiquidityPolicy.Auto(policy.maxRelativeFeeBasisPoints, policy.maxAbsoluteFee, policy.skipAbsoluteFeeCheck) is LiquidityPolicy.Disable -> InternalLiquidityPolicy.Disable From 42693178783ab854210c129c0a2b64262b04a565 Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Wed, 13 Dec 2023 13:21:52 +0100 Subject: [PATCH 03/10] Add database and queries for inbound liquidity payments A liquidity lease rate is provided to the peer at startup using default values stored in the NodeParamsManager. Aggregated queries have been updated. Note that inbound liquidity payments use the `lockedAt` timestamp to define if the payment is complete or not. Also added a new `WalletPaymentId` for inbound liquidity payments, with internal code 6. Tests are missing. Cloud wrappers for inbound liquidity payments are not yet implemented. --- .../fr.acinq.phoenix/data/WalletPayment.kt | 33 ++++-- .../fr.acinq.phoenix/db/SqlitePaymentsDb.kt | 31 ++++++ .../db/payments/InboundLiquidityLeaseType.kt | 104 ++++++++++++++++++ .../db/payments/InboundLiquidityQueries.kt | 91 +++++++++++++++ .../db/serializers/v1/ByteVectorSerializer.kt | 14 +++ .../managers/NodeParamsManager.kt | 11 +- .../managers/PaymentsManager.kt | 1 + .../fr.acinq.phoenix/managers/PeerManager.kt | 4 +- .../fr.acinq.phoenix/utils/CsvWriter.kt | 1 + .../utils/extensions/PaymentExtensions.kt | 4 + .../fr.acinq.phoenix.db/AggregatedQueries.sq | 31 ++++++ .../InboundLiquidityOutgoing.sq | 34 ++++++ .../fr.acinq.phoenix.db/migrations/8.sqm | 18 +++ .../db/IncomingPaymentDbTypeVersionTest.kt | 9 +- .../phoenix/db/SqlitePaymentsDatabaseTest.kt | 10 +- .../acinq/phoenix/db/cloud/CloudDataTest.kt | 13 ++- .../serializers/OutpointDbSerializerTest.kt | 7 +- .../fr/acinq/phoenix/utils/CsvWriterTests.kt | 9 +- 18 files changed, 392 insertions(+), 33 deletions(-) create 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/InboundLiquidityQueries.kt create mode 100644 phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/InboundLiquidityOutgoing.sq create mode 100644 phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/migrations/8.sqm diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/WalletPayment.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/WalletPayment.kt index 30d234419..af685367d 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/WalletPayment.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/WalletPayment.kt @@ -21,71 +21,89 @@ sealed class WalletPaymentId { abstract val dbType: DbType abstract val dbId: String + /** Use this to get a single (hashable) identifier for the row, for example within a hashmap or Cache. */ abstract val identifier: String - data class IncomingPaymentId(val paymentHash: ByteVector32): WalletPaymentId() { + data class IncomingPaymentId(val paymentHash: ByteVector32) : WalletPaymentId() { override val dbType: DbType = DbType.INCOMING override val dbId: String = paymentHash.toHex() override val identifier: String = "incoming|$dbId" + companion object { fun fromString(id: String) = IncomingPaymentId(paymentHash = ByteVector32(id)) fun fromByteArray(id: ByteArray) = IncomingPaymentId(paymentHash = ByteVector32(id)) } } - data class LightningOutgoingPaymentId(val id: UUID): WalletPaymentId() { + data class LightningOutgoingPaymentId(val id: UUID) : WalletPaymentId() { override val dbType: DbType = DbType.OUTGOING override val dbId: String = id.toString() override val identifier: String = "outgoing|$dbId" + companion object { fun fromString(id: String) = LightningOutgoingPaymentId(id = UUID.fromString(id)) } } - data class SpliceOutgoingPaymentId(val id: UUID): WalletPaymentId() { + data class SpliceOutgoingPaymentId(val id: UUID) : WalletPaymentId() { override val dbType: DbType = DbType.SPLICE_OUTGOING override val dbId: String = id.toString() override val identifier: String = "splice_outgoing|$dbId" + companion object { fun fromString(id: String) = SpliceOutgoingPaymentId(id = UUID.fromString(id)) } } - data class ChannelCloseOutgoingPaymentId(val id: UUID): WalletPaymentId() { + data class ChannelCloseOutgoingPaymentId(val id: UUID) : WalletPaymentId() { override val dbType: DbType = DbType.CHANNEL_CLOSE_OUTGOING override val dbId: String = id.toString() override val identifier: String = "channel_close_outgoing|$dbId" + companion object { fun fromString(id: String) = ChannelCloseOutgoingPaymentId(id = UUID.fromString(id)) } } - data class SpliceCpfpOutgoingPaymentId(val id: UUID): WalletPaymentId() { + data class SpliceCpfpOutgoingPaymentId(val id: UUID) : WalletPaymentId() { override val dbType: DbType = DbType.SPLICE_CPFP_OUTGOING override val dbId: String = id.toString() override val identifier: String = "splice_cpfp_outgoing|$dbId" + companion object { fun fromString(id: String) = SpliceCpfpOutgoingPaymentId(id = UUID.fromString(id)) } } + data class InboundLiquidityOutgoingPaymentId(val id: UUID) : WalletPaymentId() { + override val dbType: DbType = DbType.INBOUND_LIQUIDITY_OUTGOING + override val dbId: String = id.toString() + override val identifier: String = "inbound_liquidity_outgoing|$dbId" + + companion object { + fun fromString(id: String) = InboundLiquidityOutgoingPaymentId(id = UUID.fromString(id)) + } + } + enum class DbType(val value: Long) { INCOMING(1), OUTGOING(2), SPLICE_OUTGOING(3), CHANNEL_CLOSE_OUTGOING(4), SPLICE_CPFP_OUTGOING(5), + INBOUND_LIQUIDITY_OUTGOING(6), } companion object { fun create(type: Long, id: String): WalletPaymentId? { - return when(type) { + return when (type) { DbType.INCOMING.value -> IncomingPaymentId.fromString(id) DbType.OUTGOING.value -> LightningOutgoingPaymentId.fromString(id) DbType.SPLICE_OUTGOING.value -> SpliceOutgoingPaymentId.fromString(id) DbType.CHANNEL_CLOSE_OUTGOING.value -> ChannelCloseOutgoingPaymentId.fromString(id) DbType.SPLICE_CPFP_OUTGOING.value -> SpliceCpfpOutgoingPaymentId.fromString(id) + DbType.INBOUND_LIQUIDITY_OUTGOING.value -> InboundLiquidityOutgoingPaymentId.fromString(id) else -> null } } @@ -98,6 +116,7 @@ fun WalletPayment.walletPaymentId(): WalletPaymentId = when (this) { is SpliceOutgoingPayment -> WalletPaymentId.SpliceOutgoingPaymentId(id = this.id) is ChannelCloseOutgoingPayment -> WalletPaymentId.ChannelCloseOutgoingPaymentId(id = this.id) is SpliceCpfpOutgoingPayment -> WalletPaymentId.SpliceCpfpOutgoingPaymentId(id = this.id) + is InboundLiquidityOutgoingPayment -> WalletPaymentId.InboundLiquidityOutgoingPaymentId(id = this.id) } /** @@ -153,7 +172,7 @@ data class LnurlPayMetadata( val description: String, val successAction: LnurlPay.Invoice.SuccessAction? ) { - companion object {/* allow companion extensions */} + companion object { /* allow companion extensions */ } } /** 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 a5641f03d..fd33a8c6b 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 @@ -78,6 +78,9 @@ 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() ) ) @@ -89,6 +92,7 @@ class SqlitePaymentsDb( private val aggrQueries = database.aggregatedQueriesQueries private val metaQueries = MetadataQueries(database) private val linkTxToPaymentQueries = LinkTxToPaymentQueries(database) + private val inboundLiquidityQueries = InboundLiquidityQueries(database) private val cloudKitDb = makeCloudKitDb(database) @@ -137,6 +141,10 @@ class SqlitePaymentsDb( cpfpQueries.addCpfpPayment(outgoingPayment) linkTxToPaymentQueries.linkTxToPayment(outgoingPayment.txId, outgoingPayment.walletPaymentId()) } + is InboundLiquidityOutgoingPayment -> { + inboundLiquidityQueries.add(outgoingPayment) + linkTxToPaymentQueries.linkTxToPayment(outgoingPayment.txId, outgoingPayment.walletPaymentId()) + } } // Add associated metadata within the same atomic database transaction. if (!metadataRow.isEmpty()) { @@ -263,6 +271,18 @@ class SqlitePaymentsDb( } } + suspend fun getInboundLiquidityOutgoingPayment( + id: UUID, + options: WalletPaymentFetchOptions + ): Pair? = withContext(Dispatchers.Default) { + database.transactionWithResult { + inboundLiquidityQueries.get(id)?.let { + val metadata = metaQueries.getMetadata(id = it.walletPaymentId(), options) + it to metadata + } + } + } + // ---- list outgoing override suspend fun listLightningOutgoingPayments( @@ -331,6 +351,9 @@ class SqlitePaymentsDb( is WalletPaymentId.SpliceCpfpOutgoingPaymentId -> { cpfpQueries.setLocked(walletPaymentId.id, lockedAt) } + is WalletPaymentId.InboundLiquidityOutgoingPaymentId -> { + inboundLiquidityQueries.setLocked(walletPaymentId.id, lockedAt) + } } } } @@ -357,6 +380,9 @@ class SqlitePaymentsDb( is WalletPaymentId.SpliceCpfpOutgoingPaymentId -> { cpfpQueries.setConfirmed(walletPaymentId.id, confirmedAt) } + is WalletPaymentId.InboundLiquidityOutgoingPaymentId -> { + inboundLiquidityQueries.setConfirmed(walletPaymentId.id, confirmedAt) + } } } } @@ -377,6 +403,7 @@ class SqlitePaymentsDb( is WalletPaymentId.ChannelCloseOutgoingPaymentId -> channelCloseQueries.getChannelCloseOutgoingPayment(it.id) is WalletPaymentId.SpliceCpfpOutgoingPaymentId -> cpfpQueries.getCpfp(it.id) is WalletPaymentId.SpliceOutgoingPaymentId -> spliceOutQueries.getSpliceOutPayment(it.id) + is WalletPaymentId.InboundLiquidityOutgoingPaymentId -> inboundLiquidityQueries.get(it.id) } } } @@ -614,6 +641,9 @@ class SqlitePaymentsDb( id = paymentId.dbId ) } + is WalletPaymentId.InboundLiquidityOutgoingPaymentId -> { + database.inboundLiquidityOutgoingQueries.delete(id = paymentId.dbId) + } } didDeleteWalletPayment(paymentId, database) } @@ -641,6 +671,7 @@ class SqlitePaymentsDb( WalletPaymentId.DbType.SPLICE_OUTGOING.value -> WalletPaymentId.SpliceOutgoingPaymentId.fromString(id) WalletPaymentId.DbType.CHANNEL_CLOSE_OUTGOING.value -> WalletPaymentId.ChannelCloseOutgoingPaymentId.fromString(id) WalletPaymentId.DbType.SPLICE_CPFP_OUTGOING.value -> WalletPaymentId.SpliceCpfpOutgoingPaymentId.fromString(id) + WalletPaymentId.DbType.INBOUND_LIQUIDITY_OUTGOING.value -> WalletPaymentId.InboundLiquidityOutgoingPaymentId.fromString(id) else -> throw UnhandledPaymentType(type) } return WalletPaymentOrderRow( 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 new file mode 100644 index 000000000..c45249b8b --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/InboundLiquidityLeaseType.kt @@ -0,0 +1,104 @@ +/* + * 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 new file mode 100644 index 000000000..869e8fe09 --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/InboundLiquidityQueries.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2023 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.db.payments + +import fr.acinq.bitcoin.TxId +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.phoenix.data.WalletPaymentId +import fr.acinq.phoenix.db.PaymentsDatabase +import fr.acinq.phoenix.db.didSaveWalletPayment + +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, + created_at = payment.createdAt, + confirmed_at = payment.confirmedAt, + locked_at = payment.lockedAt, + ) + } + } + + fun get(id: UUID): InboundLiquidityOutgoingPayment? { + return queries.get(id = id.toString(), mapper = ::mapPayment) + .executeAsOneOrNull() + } + + fun setConfirmed(id: UUID, confirmedAt: Long) { + database.transaction { + queries.setConfirmed(confirmed_at = confirmedAt, id = id.toString()) + didSaveWalletPayment(WalletPaymentId.InboundLiquidityOutgoingPaymentId(id), database) + } + } + + fun setLocked(id: UUID, lockedAt: Long) { + database.transaction { + queries.setLocked(locked_at = lockedAt, id = id.toString()) + didSaveWalletPayment(WalletPaymentId.InboundLiquidityOutgoingPaymentId(id), database) + } + } + + private companion object { + fun mapPayment( + id: String, + mining_fees_sat: Long, + channel_id: ByteArray, + tx_id: ByteArray, + lease_type: InboundLiquidityLeaseTypeVersion, + lease_blob: ByteArray, + created_at: Long, + confirmed_at: Long?, + locked_at: Long? + ): InboundLiquidityOutgoingPayment { + return InboundLiquidityOutgoingPayment( + id = UUID.fromString(id), + miningFees = mining_fees_sat.sat, + channelId = channel_id.toByteVector32(), + txId = TxId(tx_id), + lease = InboundLiquidityLeaseData.deserialize(lease_type, lease_blob), + createdAt = created_at, + confirmedAt = confirmed_at, + lockedAt = locked_at + ) + } + } +} \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serializers/v1/ByteVectorSerializer.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serializers/v1/ByteVectorSerializer.kt index 3de0bce26..19d85cf07 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serializers/v1/ByteVectorSerializer.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serializers/v1/ByteVectorSerializer.kt @@ -16,7 +16,9 @@ package fr.acinq.phoenix.db.serializers.v1 +import fr.acinq.bitcoin.ByteVector import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.ByteVector64 object ByteVector32Serializer : AbstractStringSerializer( @@ -24,3 +26,15 @@ object ByteVector32Serializer : AbstractStringSerializer( toString = ByteVector32::toHex, fromString = ::ByteVector32 ) + +object ByteVector64Serializer : AbstractStringSerializer( + name = "ByteVector64", + toString = ByteVector64::toHex, + fromString = ::ByteVector64 +) + +object ByteVectorSerializer : AbstractStringSerializer( + name = "ByteVector", + toString = ByteVector::toHex, + fromString = ::ByteVector +) diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NodeParamsManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NodeParamsManager.kt index 931188335..3391015e8 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 @@ -20,7 +20,9 @@ import fr.acinq.bitcoin.PublicKey import fr.acinq.lightning.NodeParams import fr.acinq.lightning.NodeUri import fr.acinq.lightning.payment.LiquidityPolicy +import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.shared.BuildVersions import kotlinx.coroutines.CoroutineScope @@ -80,6 +82,13 @@ class NodeParamsManager( 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) - const val swapInConfirmations = 3 + val liquidityLeaseRate = LiquidityAds.LeaseRate( + leaseDuration = 0, + fundingWeight = 609, // 1-input (wpkh)/2-outputs (wpkh+wsh) + leaseFeeProportional = 100, // 1% + leaseFeeBase = 0.sat, + maxRelayFeeProportional = 100, + maxRelayFeeBase = 1_000.msat + ) } } \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentsManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentsManager.kt index cae7bf09d..6f65a60e6 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentsManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentsManager.kt @@ -159,6 +159,7 @@ class PaymentsManager( is WalletPaymentId.SpliceOutgoingPaymentId -> paymentsDb().getSpliceOutgoingPayment(id.id, options) is WalletPaymentId.ChannelCloseOutgoingPaymentId -> paymentsDb().getChannelCloseOutgoingPayment(id.id, options) is WalletPaymentId.SpliceCpfpOutgoingPaymentId -> paymentsDb().getSpliceCpfpOutgoingPayment(id.id, options) + is WalletPaymentId.InboundLiquidityOutgoingPaymentId -> paymentsDb().getInboundLiquidityOutgoingPayment(id.id, options) }?.let { WalletPaymentInfo( payment = it.first, 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 fde85d2c0..dc3257cd4 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 @@ -13,7 +13,6 @@ import fr.acinq.lightning.UpgradeRequired import fr.acinq.lightning.WalletParams import fr.acinq.lightning.blockchain.electrum.ElectrumWatcher import fr.acinq.lightning.blockchain.electrum.WalletState -import fr.acinq.lightning.blockchain.electrum.balance import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.states.ChannelStateWithCommitments import fr.acinq.lightning.channel.states.Normal @@ -27,8 +26,6 @@ import fr.acinq.lightning.wire.InitTlv import fr.acinq.lightning.wire.TlvStream import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.data.LocalChannelInfo -import fr.acinq.phoenix.utils.extensions.deeplyConfirmedToExpiry -import fr.acinq.phoenix.utils.extensions.timeoutIn import fr.acinq.phoenix.utils.extensions.isTerminated import fr.acinq.phoenix.utils.extensions.nextTimeout import kotlinx.coroutines.* @@ -182,6 +179,7 @@ class PeerManager( socketBuilder = null, scope = MainScope() ) + peer.liquidityRatesFlow.value = NodeParamsManager.liquidityLeaseRate _peer.value = peer launch { monitorNodeEvents(nodeParams) } 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 67da1a71b..a230b7aa6 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 @@ -139,6 +139,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 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 f5a23b603..07fccc441 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 @@ -29,6 +29,10 @@ fun WalletPayment.id(): String = when (this) { } fun WalletPayment.state(): WalletPaymentState = when (this) { + is InboundLiquidityOutgoingPayment -> when (lockedAt) { + null -> WalletPaymentState.PendingOnChain + else -> WalletPaymentState.SuccessOffChain + } is OnChainOutgoingPayment -> when (confirmedAt) { null -> WalletPaymentState.PendingOnChain else -> WalletPaymentState.SuccessOnChain diff --git a/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/AggregatedQueries.sq b/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/AggregatedQueries.sq index 6c1edfc7d..6f8ba0b1a 100644 --- a/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/AggregatedQueries.sq +++ b/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/AggregatedQueries.sq @@ -37,6 +37,13 @@ UNION ALL created_at AS created_at, confirmed_at AS completed_at FROM splice_cpfp_outgoing_payments +UNION ALL + SELECT + 6 AS type, + id AS id, + created_at AS created_at, + locked_at AS completed_at + FROM inbound_liquidity_outgoing_payments UNION ALL SELECT 1 AS type, @@ -63,6 +70,8 @@ SELECT SUM(result) AS result FROM ( UNION ALL SELECT COUNT(*) AS result FROM splice_cpfp_outgoing_payments UNION ALL + SELECT COUNT(*) AS result FROM inbound_liquidity_outgoing_payments + UNION ALL SELECT COUNT(*) AS result FROM incoming_payments WHERE received_at IS NOT NULL AND received_with_blob IS NOT NULL ); @@ -113,6 +122,14 @@ UNION ALL confirmed_at AS completed_at FROM splice_cpfp_outgoing_payments WHERE confirmed_at >= :date +UNION ALL + SELECT + 6 AS type, + id AS id, + created_at AS created_at, + locked_at AS completed_at + FROM inbound_liquidity_outgoing_payments + WHERE locked_at >= :date UNION ALL SELECT 1 AS type, @@ -194,6 +211,15 @@ UNION ALL FROM splice_cpfp_outgoing_payments WHERE splice_cpfp_outgoing_payments.confirmed_at IS NOT NULL AND splice_cpfp_outgoing_payments.confirmed_at BETWEEN :startDate AND :endDate +UNION ALL + SELECT + 6 AS type, + id AS id, + created_at AS created_at, + locked_at AS completed_at + FROM inbound_liquidity_outgoing_payments + WHERE inbound_liquidity_outgoing_payments.locked_at IS NOT NULL + AND inbound_liquidity_outgoing_payments.locked_at BETWEEN :startDate AND :endDate UNION ALL SELECT 1 AS type, @@ -231,6 +257,11 @@ UNION ALL FROM splice_cpfp_outgoing_payments WHERE splice_cpfp_outgoing_payments.confirmed_at IS NOT NULL AND splice_cpfp_outgoing_payments.confirmed_at BETWEEN :startDate AND :endDate +UNION ALL + SELECT COUNT(*) AS result + FROM inbound_liquidity_outgoing_payments + WHERE inbound_liquidity_outgoing_payments.locked_at IS NOT NULL + AND inbound_liquidity_outgoing_payments.locked_at BETWEEN :startDate AND :endDate UNION ALL SELECT COUNT(*) AS result FROM incoming_payments WHERE incoming_payments.received_at BETWEEN :startDate AND :endDate 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 new file mode 100644 index 000000000..aa84ff6a5 --- /dev/null +++ b/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/InboundLiquidityOutgoing.sq @@ -0,0 +1,34 @@ +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 ( + id TEXT NOT NULL PRIMARY KEY, + mining_fees_sat INTEGER NOT NULL, + channel_id BLOB NOT NULL, + tx_id BLOB NOT NULL, + lease_type TEXT AS InboundLiquidityLeaseTypeVersion NOT NULL, + lease_blob BLOB NOT NULL, + created_at INTEGER NOT NULL, + confirmed_at INTEGER DEFAULT NULL, + locked_at INTEGER DEFAULT NULL +); + +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 (?, ?, ?, ?, ?, ?, ?, ?, ?); + +setConfirmed: +UPDATE inbound_liquidity_outgoing_payments SET confirmed_at=? WHERE id=?; + +setLocked: +UPDATE inbound_liquidity_outgoing_payments SET locked_at=? WHERE id=?; + +get: +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 id=?; + +delete: +DELETE FROM inbound_liquidity_outgoing_payments WHERE id=?; diff --git a/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/migrations/8.sqm b/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/migrations/8.sqm new file mode 100644 index 000000000..26f5e8fa2 --- /dev/null +++ b/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/migrations/8.sqm @@ -0,0 +1,18 @@ +import fr.acinq.phoenix.db.payments.InboundLiquidityLeaseTypeVersion; + +-- Migration: v8 -> v9 +-- +-- Changes: +-- * add a new inbound_liquidity_outgoing_payments table to store inbound liquidity payments + +CREATE TABLE IF NOT EXISTS inbound_liquidity_outgoing_payments ( + id TEXT NOT NULL PRIMARY KEY, + mining_fees_sat INTEGER NOT NULL, + channel_id BLOB NOT NULL, + tx_id BLOB NOT NULL, + lease_type TEXT AS InboundLiquidityLeaseTypeVersion NOT NULL, + lease_blob BLOB NOT NULL, + created_at INTEGER NOT NULL, + confirmed_at INTEGER DEFAULT NULL, + locked_at INTEGER DEFAULT NULL +); diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/IncomingPaymentDbTypeVersionTest.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/IncomingPaymentDbTypeVersionTest.kt index 2bb948a8d..7932caa99 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 @@ -17,6 +17,7 @@ package fr.acinq.phoenix.db import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.TxId import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.payment.PaymentRequest import fr.acinq.lightning.utils.msat @@ -94,7 +95,7 @@ class IncomingPaymentDbTypeVersionTest { @Suppress("DEPRECATION") fun incoming_receivedwith_multipart_v0_newchannel_paytoopen() { // pay-to-open with MULTIPARTS_V0: amount contains the fee which is a special case that must be fixed when deserializing. - val receivedWith = listOf(IncomingPayment.ReceivedWith.NewChannel(1_995_000.msat, 5_000.msat, 0.sat, channelId1, ByteVector32.Zeroes, confirmedAt = 0, lockedAt = 0)) + val receivedWith = listOf(IncomingPayment.ReceivedWith.NewChannel(1_995_000.msat, 5_000.msat, 0.sat, channelId1, TxId(ByteVector32.Zeroes), confirmedAt = 0, lockedAt = 0)) val deserialized = IncomingReceivedWithData.deserialize( IncomingReceivedWithTypeVersion.MULTIPARTS_V0, Hex.decode("5b7b2274797065223a2266722e6163696e712e70686f656e69782e64622e7061796d656e74732e496e636f6d696e67526563656976656457697468446174612e506172742e4e65774368616e6e656c2e5630222c22616d6f756e74223a7b226d736174223a323030303030307d2c2266656573223a7b226d736174223a353030307d2c226368616e6e656c4964223a2233623632303832383535363363396164623030393738316163663136323666316332613362316133343932643565633331326561643832383263376164366461227d5d"), @@ -107,7 +108,7 @@ class IncomingPaymentDbTypeVersionTest { @Test fun incoming_receivedwith_multipart_v1_newchannel_paytoopen() { - val receivedWith = listOf(IncomingPayment.ReceivedWith.NewChannel(1_995_000.msat, 5_000.msat, 0.sat, channelId1, ByteVector32.Zeroes, confirmedAt = 10, lockedAt = 20)) + val receivedWith = listOf(IncomingPayment.ReceivedWith.NewChannel(1_995_000.msat, 5_000.msat, 0.sat, channelId1, TxId(ByteVector32.Zeroes), confirmedAt = 10, lockedAt = 20)) val deserialized = IncomingReceivedWithData.deserialize( IncomingReceivedWithTypeVersion.MULTIPARTS_V1, receivedWith.mapToDb()!!.second, @@ -120,7 +121,7 @@ class IncomingPaymentDbTypeVersionTest { @Test @Suppress("DEPRECATION") fun incoming_receivedwith_multipart_v0_newchannel_swapin_nochannel() { - val receivedWith = listOf(IncomingPayment.ReceivedWith.NewChannel(111111111.msat, 1000.msat, 0.sat, ByteVector32.Zeroes, ByteVector32.Zeroes, confirmedAt = 0, lockedAt = 0)) + val receivedWith = listOf(IncomingPayment.ReceivedWith.NewChannel(111111111.msat, 1000.msat, 0.sat, ByteVector32.Zeroes, TxId(ByteVector32.Zeroes), confirmedAt = 0, lockedAt = 0)) val deserialized = IncomingReceivedWithData.deserialize( IncomingReceivedWithTypeVersion.MULTIPARTS_V0, Hex.decode("5b7b2274797065223a2266722e6163696e712e70686f656e69782e64622e7061796d656e74732e496e636f6d696e67526563656976656457697468446174612e506172742e4e65774368616e6e656c2e5630222c22616d6f756e74223a7b226d736174223a3131313131313131317d2c2266656573223a7b226d736174223a313030307d2c226368616e6e656c4964223a2230303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030227d5d"), @@ -132,7 +133,7 @@ class IncomingPaymentDbTypeVersionTest { @Test fun incoming_receivedwith_multipart_v1_newchannel_swapin_nochannel() { - val receivedWith = listOf(IncomingPayment.ReceivedWith.NewChannel(164495787.msat, 4058671.msat, 0.sat, ByteVector32.Zeroes, ByteVector32.Zeroes, confirmedAt = 10, lockedAt = 20)) + val receivedWith = listOf(IncomingPayment.ReceivedWith.NewChannel(164495787.msat, 4058671.msat, 0.sat, ByteVector32.Zeroes, TxId(ByteVector32.Zeroes), confirmedAt = 10, lockedAt = 20)) val deserialized = IncomingReceivedWithData.deserialize( IncomingReceivedWithTypeVersion.MULTIPARTS_V1, receivedWith.mapToDb()!!.second, 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 82e9e31ec..269375a0b 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 @@ -46,7 +46,7 @@ class SqlitePaymentsDatabaseTest { private val preimage2 = randomBytes32() private val receivedWith2 = listOf( - IncomingPayment.ReceivedWith.NewChannel(amount = 1_995_000.msat, serviceFee = 5_000.msat, channelId = randomBytes32(), txId = randomBytes32(), miningFee = 100.sat, confirmedAt = 100, lockedAt = 200) + IncomingPayment.ReceivedWith.NewChannel(amount = 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") @@ -87,7 +87,7 @@ class SqlitePaymentsDatabaseTest { val paymentHash = Crypto.sha256(preimage).toByteVector32() val origin = IncomingPayment.Origin.Invoice(createInvoice(preimage, 1_000_000_000.msat)) val channelId = randomBytes32() - val txId = 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 receivedWith = listOf(mppPart1, mppPart2) @@ -103,7 +103,7 @@ class SqlitePaymentsDatabaseTest { val paymentHash = Crypto.sha256(preimage).toByteVector32() val origin = IncomingPayment.Origin.Invoice(createInvoice(preimage, 1_000_000_000.msat)) val channelId = randomBytes32() - val txId = 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 receivedWith = listOf(mppPart1, mppPart2) @@ -201,7 +201,7 @@ class SqlitePaymentsDatabaseTest { isSentToDefaultAddress = false, miningFees = 500.sat, channelId = randomBytes32(), - txId = randomBytes32(), + txId = TxId(randomBytes32()), createdAt = 100, confirmedAt = null, lockedAt = null, @@ -238,7 +238,7 @@ class SqlitePaymentsDatabaseTest { assertEquals("foobar", close.address) assertEquals(ByteVector32.Zeroes, close.channelId) assertEquals(true, close.isSentToDefaultAddress) - assertEquals("ecf2b7c9cfa745e23f4b6a47f9ceb19b0f630e0d73e4442ef326d3da24c903f5", close.txId.toHex()) + assertEquals("ecf2b7c9cfa745e23f4b6a47f9ceb19b0f630e0d73e4442ef326d3da24c903f5", close.txId.toString()) assertEquals(ChannelClosingType.Local, close.closingType) assertEquals(100, close.createdAt) assertEquals(200, close.confirmedAt) 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 8b575f201..531a61718 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 @@ -50,6 +50,7 @@ class CloudDataTest { is SpliceOutgoingPayment -> data.spliceOutgoing?.unwrap() is ChannelCloseOutgoingPayment -> data.channelClose?.unwrap() is SpliceCpfpOutgoingPayment -> data.spliceCpfp?.unwrap() + is InboundLiquidityOutgoingPayment -> TODO() } assertNotNull(decoded) @@ -113,7 +114,7 @@ class CloudDataTest { fun incoming__receivedWith_newChannel() = runTest { val invoice = createInvoice(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 = randomBytes32(), confirmedAt = 500, lockedAt = 800 + amount = 7_000_000.msat, miningFee = 2_000.sat, serviceFee = 1_000_000.msat, channelId = channelId, txId = TxId(randomBytes32()), confirmedAt = 500, lockedAt = 800 ) testRoundtrip( IncomingPayment( @@ -136,8 +137,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 = 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 = ByteVector32.Zeroes, confirmedAt = 0, lockedAt = 0) + 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) ), receivedAt = 1658246347319 ) @@ -197,7 +198,7 @@ class CloudDataTest { address = bitcoinAddress, isSentToDefaultAddress = false, miningFees = 1_000.sat, - txId = randomBytes32(), + txId = TxId(randomBytes32()), createdAt = 1000, confirmedAt = null, lockedAt = null, @@ -216,7 +217,7 @@ class CloudDataTest { address = bitcoinAddress, isSentToDefaultAddress = true, miningFees = 5_000.sat, - txId = randomBytes32(), + txId = TxId(randomBytes32()), createdAt = 1000, confirmedAt = 5000, lockedAt = 7000, @@ -299,7 +300,7 @@ class CloudDataTest { recipientAmount = 1_000_000.sat, address = bitcoinAddress, miningFees = 3400.sat, - txId = randomBytes32(), + txId = TxId(randomBytes32()), channelId = randomBytes32(), createdAt = 150, confirmedAt = 240, diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/serializers/OutpointDbSerializerTest.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/serializers/OutpointDbSerializerTest.kt index fd804cff2..72a5e8ba7 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/serializers/OutpointDbSerializerTest.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/serializers/OutpointDbSerializerTest.kt @@ -5,6 +5,7 @@ package fr.acinq.phoenix.db.serializers import fr.acinq.bitcoin.OutPoint +import fr.acinq.bitcoin.TxHash import fr.acinq.bitcoin.byteVector32 import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.phoenix.db.serializers.v1.OutpointSerializer @@ -27,10 +28,10 @@ class OutpointDbDbSerializerTest { @Test fun serialize_outpoint() { - val txHash1 = randomBytes32() + val txHash1 = TxHash(randomBytes32()) val outpoint1 = OutPoint(txHash1, 1) //"1:$txId" assertEquals("\"$txHash1:1\"", json.encodeToString(outpoint1)) - val txHash2 = randomBytes32() + val txHash2 = TxHash(randomBytes32()) val outpoint2 = OutPoint(txHash2, 999) assertEquals("[\"$txHash1:1\",\"$txHash2:999\"]", json.encodeToString(listOf(outpoint1, outpoint2))) println(json.encodeToString(listOf(outpoint1, outpoint2))) @@ -41,7 +42,7 @@ class OutpointDbDbSerializerTest { val data = "[\"dba843431559d17371c1c10b3d2c1c1568ca0afb4ef6a4dd2b348fc54967fcc2:1\",\"33f088b296f0a3e56fa58df6b5d362a5202a6f5c0086980feb69de1e6a8618f5:999\"]" assertEquals( Hex.decode("dba843431559d17371c1c10b3d2c1c1568ca0afb4ef6a4dd2b348fc54967fcc2").byteVector32(), - json.decodeFromString>(data)[0].hash + json.decodeFromString>(data)[0].hash.value ) assertEquals( 999, 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 dbda6fc69..0fdd9b1c3 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 @@ -3,6 +3,7 @@ package fr.acinq.phoenix.utils import fr.acinq.bitcoin.Block import fr.acinq.bitcoin.PrivateKey import fr.acinq.bitcoin.PublicKey +import fr.acinq.bitcoin.TxId import fr.acinq.lightning.CltvExpiryDelta import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.MilliSatoshi @@ -42,7 +43,7 @@ class CsvWriterTests { serviceFee = 3_000_000.msat, miningFee = 0.sat, channelId = randomBytes32(), - txId = randomBytes32(), + txId = TxId(randomBytes32()), confirmedAt = 1000, lockedAt = 2000, ) @@ -229,7 +230,7 @@ class CsvWriterTests { fun testRow_Incoming_NewChannel_DualSwapIn() { val payment = IncomingPayment( preimage = randomBytes32(), - origin = IncomingPayment.Origin.OnChain(txid = randomBytes32(), localInputs = setOf()), + origin = IncomingPayment.Origin.OnChain(txId = TxId(randomBytes32()), localInputs = setOf()), received = IncomingPayment.Received( receivedWith = listOf( IncomingPayment.ReceivedWith.NewChannel( @@ -237,7 +238,7 @@ class CsvWriterTests { serviceFee = 2_931_000.msat, miningFee = 69.sat, channelId = randomBytes32(), - txId = randomBytes32(), + txId = TxId(randomBytes32()), confirmedAt = 500, lockedAt = 1000 ) @@ -305,7 +306,7 @@ class CsvWriterTests { isSentToDefaultAddress = false, miningFees = 1_400.sat, channelId = randomBytes32(), - txId = randomBytes32(), + txId = TxId(randomBytes32()), createdAt = 1675353533694, confirmedAt = 1675353533694, lockedAt = null, From 55d04c35cc7654ca3ad27595bf85c528ed7bafa4 Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Wed, 13 Dec 2023 15:17:14 +0100 Subject: [PATCH 04/10] Fix fundingWeight in lease rate --- .../kotlin/fr.acinq.phoenix/managers/NodeParamsManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3391015e8..6a460fcca 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 @@ -84,7 +84,7 @@ class NodeParamsManager( val defaultLiquidityPolicy = LiquidityPolicy.Auto(maxAbsoluteFee = 5_000.sat, maxRelativeFeeBasisPoints = 50_00 /* 50% */, skipAbsoluteFeeCheck = false) val liquidityLeaseRate = LiquidityAds.LeaseRate( leaseDuration = 0, - fundingWeight = 609, // 1-input (wpkh)/2-outputs (wpkh+wsh) + fundingWeight = 271 * 2, // 2-inputs (wpkh)/ 0-change leaseFeeProportional = 100, // 1% leaseFeeBase = 0.sat, maxRelayFeeProportional = 100, From f5c511d33e349472300a8a573071265075757e55 Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Thu, 14 Dec 2023 11:51:37 +0100 Subject: [PATCH 05/10] (android) Add UI for requesting liquidity A new screen has been added to request liquidity from the peer using liquidity-ads. The user can pick the an amount, and get an estimation of the cost (mining + service fee) of the liquidity. Then accept the offer, or cancel the request by leaving the screen. The liquidity is done with a splice. A button to that screen has been added in the home screen and in the liquidity policy screen. The channels view screen also shows the current inbound liquidity, using a linear progress bar. --- .../fr/acinq/phoenix/android/AppView.kt | 12 +- .../fr/acinq/phoenix/android/Navigation.kt | 1 + .../phoenix/android/components/AmountView.kt | 27 +- .../phoenix/android/components/Buttons.kt | 3 +- .../android/components/FeerateSlider.kt | 2 +- .../android/components/SatoshiLogSlider.kt | 86 +++++ .../android/components/SatoshiSlider.kt | 41 ++- .../phoenix/android/components/Settings.kt | 17 +- .../phoenix/android/home/HomeTopAndBottom.kt | 48 ++- .../fr/acinq/phoenix/android/home/HomeView.kt | 4 +- .../phoenix/android/payments/cpfp/CpfpView.kt | 38 ++- .../android/payments/cpfp/CpfpViewModel.kt | 19 +- .../details/PaymentDetailsSplashView.kt | 58 +++- .../details/PaymentDetailsTechnicalView.kt | 45 ++- .../liquidity/RequestLiquidityView.kt | 306 ++++++++++++++++++ .../liquidity/RequestLiquidityViewModel.kt | 111 +++++++ .../payments/spliceout/SpliceOutView.kt | 28 +- .../payments/spliceout/SpliceOutViewModel.kt | 18 +- .../settings/channels/ChannelDetailsView.kt | 14 +- .../android/settings/channels/ChannelsView.kt | 68 +++- .../settings/fees/LiquidityPolicyView.kt | 28 +- .../acinq/phoenix/android/utils/extensions.kt | 2 + .../src/main/res/drawable/ic_inspect.xml | 20 ++ .../src/main/res/values/important_strings.xml | 33 ++ .../src/main/res/values/strings.xml | 13 + 25 files changed, 940 insertions(+), 102 deletions(-) create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/SatoshiLogSlider.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityView.kt create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityViewModel.kt create mode 100644 phoenix-android/src/main/res/drawable/ic_inspect.xml diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt index 33f1f01fa..5b9751943 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt @@ -78,6 +78,7 @@ import fr.acinq.phoenix.android.settings.channels.ImportChannelsData import fr.acinq.phoenix.android.settings.displayseed.DisplaySeedView import fr.acinq.phoenix.android.settings.fees.AdvancedIncomingFeePolicy import fr.acinq.phoenix.android.settings.fees.LiquidityPolicyView +import fr.acinq.phoenix.android.payments.liquidity.RequestLiquidityView import fr.acinq.phoenix.android.settings.walletinfo.FinalWalletInfo import fr.acinq.phoenix.android.settings.walletinfo.SwapInWalletInfo import fr.acinq.phoenix.android.settings.walletinfo.WalletInfoView @@ -219,7 +220,8 @@ fun AppView( onTorClick = { navController.navigate(Screen.TorConfig) }, onElectrumClick = { navController.navigate(Screen.ElectrumServer) }, onShowSwapInWallet = { navController.navigate(Screen.WalletInfo.SwapInWallet) }, - onShowNotifications = { navController.navigate(Screen.Notifications) } + onShowNotifications = { navController.navigate(Screen.Notifications) }, + onRequestLiquidityClick = { navController.navigate(Screen.LiquidityRequest.route) }, ) } } @@ -322,7 +324,7 @@ fun AppView( } }, onChannelClick = { navController.navigate("${Screen.ChannelDetails.route}?id=$it") }, - onImportChannelsDataClick = { navController.navigate(Screen.ImportChannelsData)} + onImportChannelsDataClick = { navController.navigate(Screen.ImportChannelsData)}, ) } composable( @@ -394,9 +396,13 @@ fun AppView( composable(Screen.LiquidityPolicy.route, deepLinks = listOf(navDeepLink { uriPattern ="phoenix:liquiditypolicy" })) { LiquidityPolicyView( onBackClick = { navController.popBackStack() }, - onAdvancedClick = { navController.navigate(Screen.AdvancedLiquidityPolicy.route) } + onAdvancedClick = { navController.navigate(Screen.AdvancedLiquidityPolicy.route) }, + onRequestLiquidityClick = { navController.navigate(Screen.LiquidityRequest.route) }, ) } + composable(Screen.LiquidityRequest.route, deepLinks = listOf(navDeepLink { uriPattern ="phoenix:requestliquidity" })) { + RequestLiquidityView(onBackClick = { navController.popBackStack() },) + } composable(Screen.AdvancedLiquidityPolicy.route) { AdvancedIncomingFeePolicy(onBackClick = { navController.popBackStack() }) } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/Navigation.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/Navigation.kt index dbb3892a1..6241de65e 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/Navigation.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/Navigation.kt @@ -60,6 +60,7 @@ sealed class Screen(val route: String) { object FinalWallet: Screen("settings/walletinfo/final") } object LiquidityPolicy: Screen("settings/liquiditypolicy") + object LiquidityRequest: Screen("settings/requestliquidity") object AdvancedLiquidityPolicy: Screen("settings/advancedliquiditypolicy") object Notifications: Screen("notifications") object ResetWallet: Screen("settings/resetwallet") diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountView.kt index 6b2df764b..e4b4ccaa4 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountView.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import fr.acinq.lightning.MilliSatoshi import fr.acinq.phoenix.android.* import fr.acinq.phoenix.android.R @@ -148,7 +149,7 @@ fun AmountWithAltView( fun ColumnScope.AmountWithFiatBelow( amount: MilliSatoshi, amountTextStyle: TextStyle = MaterialTheme.typography.body1, - fiatTextStyle: TextStyle = MaterialTheme.typography.caption, + fiatTextStyle: TextStyle = MaterialTheme.typography.caption.copy(fontSize = 14.sp), ) { val prefBtcUnit = LocalBitcoinUnit.current val prefFiatCurrency = LocalFiatCurrency.current @@ -162,6 +163,30 @@ fun ColumnScope.AmountWithFiatBelow( ) } +/** Outputs a column with the amount in bitcoin on top, and the fiat amount below. */ +@Composable +fun AmountWithFiatBeside( + amount: MilliSatoshi, + amountTextStyle: TextStyle = MaterialTheme.typography.body1, + fiatTextStyle: TextStyle = MaterialTheme.typography.caption.copy(fontSize = 14.sp), +) { + val prefBtcUnit = LocalBitcoinUnit.current + val prefFiatCurrency = LocalFiatCurrency.current + Row { + Text( + text = amount.toPrettyString(prefBtcUnit, withUnit = true), + style = amountTextStyle, + modifier = Modifier.alignByBaseline(), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringResource(id = R.string.utils_converted_amount, amount.toPrettyString(prefFiatCurrency, fiatRate, withUnit = true)), + style = fiatTextStyle, + modifier = Modifier.alignByBaseline(), + ) + } +} + /** Outputs a row with the amount in bitcoin on the left, and the fiat amount on the right. */ @Composable fun AmountWithFiatRowView( diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Buttons.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Buttons.kt index a230d9012..1c8086bed 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Buttons.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Buttons.kt @@ -60,6 +60,7 @@ fun BorderButton( text: String? = null, icon: Int? = null, iconTint: Color = MaterialTheme.colors.primary, + shape: Shape = CircleShape, backgroundColor: Color = MaterialTheme.colors.surface, borderColor: Color = MaterialTheme.colors.primary, enabled: Boolean = true, @@ -78,7 +79,7 @@ fun BorderButton( enabledEffect = enabledEffect, space = space, onClick = onClick, - shape = CircleShape, + shape = shape, backgroundColor = backgroundColor, border = BorderStroke(ButtonDefaults.OutlinedBorderSize, if (enabled) borderColor else borderColor.copy(alpha = 0.4f)), textStyle = textStyle, diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/FeerateSlider.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/FeerateSlider.kt index aad0f3c7c..b49d9a667 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/FeerateSlider.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/FeerateSlider.kt @@ -90,7 +90,7 @@ fun FeerateSlider( } } - SatoshiSlider( + SatoshiLogSlider( modifier = Modifier .widthIn(max = 130.dp) .offset(x = (-4).dp, y = (-8).dp), diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/SatoshiLogSlider.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/SatoshiLogSlider.kt new file mode 100644 index 000000000..17906f852 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/SatoshiLogSlider.kt @@ -0,0 +1,86 @@ +/* + * 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.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Slider +import androidx.compose.material.SliderDefaults +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import fr.acinq.bitcoin.Satoshi +import fr.acinq.lightning.utils.sat +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.feedback.ErrorMessage +import kotlin.math.log10 +import kotlin.math.pow + +/** A logarithmic slider to get a Satoshi value. Can be used to get a feerate for example. */ +@Composable +fun SatoshiLogSlider( + modifier: Modifier = Modifier, + amount: Satoshi, + onAmountChange: (Satoshi) -> Unit, + minAmount: Satoshi = 1.sat, + maxAmount: Satoshi = 500.sat, + enabled: Boolean = true, + steps: Int = 30, +) { + val context = LocalContext.current + val minAmountLog = remember { log10(minAmount.sat.toFloat()) } + val maxAmountLog = remember { log10(maxAmount.sat.toFloat()) } + var amountLog by remember { mutableStateOf(log10(amount.sat.toFloat())) } + + var errorMessage by remember { mutableStateOf("") } + + Column(modifier = modifier.enableOrFade(enabled)) { + Slider( + value = amountLog, + onValueChange = { + errorMessage = "" + try { + amountLog = it + val valueSat = 10f.pow(it).toLong().sat + onAmountChange(valueSat) + } catch (e: Exception) { + errorMessage = context.getString(R.string.validation_invalid_number) + } + }, + valueRange = minAmountLog..maxAmountLog, + steps = steps, + enabled = enabled, + colors = SliderDefaults.colors( + activeTrackColor = MaterialTheme.colors.primary, + inactiveTrackColor = MaterialTheme.colors.primary.copy(alpha = 0.4f), + activeTickColor = MaterialTheme.colors.primary, + inactiveTickColor = Color.Transparent, + ) + ) + + errorMessage.takeUnless { it.isBlank() }?.let { + Spacer(Modifier.height(4.dp)) + ErrorMessage(header = it, padding = PaddingValues(0.dp)) + } + } +} + diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/SatoshiSlider.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/SatoshiSlider.kt index ec7c9f529..892547dce 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/SatoshiSlider.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/SatoshiSlider.kt @@ -23,51 +23,49 @@ import androidx.compose.foundation.layout.height import androidx.compose.material.MaterialTheme import androidx.compose.material.Slider import androidx.compose.material.SliderDefaults -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import fr.acinq.bitcoin.Satoshi -import fr.acinq.lightning.utils.sat import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.components.feedback.ErrorMessage -import kotlin.math.log10 -import kotlin.math.pow -/** A logarithmic slider to get a Satoshi value. Can be used to get a feerate for example. */ +/** A slider to pick a Satoshi value from an array of accepted values. */ @Composable fun SatoshiSlider( modifier: Modifier = Modifier, - amount: Satoshi, onAmountChange: (Satoshi) -> Unit, - minAmount: Satoshi = 1.sat, - maxAmount: Satoshi = 500.sat, + onErrorStateChange: (Boolean) -> Unit, + possibleValues: Array, enabled: Boolean = true, - steps: Int = 30, ) { val context = LocalContext.current - val minFeerateLog = remember { log10(minAmount.sat.toFloat()) } - val maxFeerateLog = remember { log10(maxAmount.sat.toFloat()) } - var feerateLog by remember { mutableStateOf(log10(amount.sat.toFloat())) } - + var index by remember { mutableStateOf(1.0f) } var errorMessage by remember { mutableStateOf("") } Column(modifier = modifier.enableOrFade(enabled)) { Slider( - value = feerateLog, + value = index, onValueChange = { errorMessage = "" try { - feerateLog = it - val valueSat = 10f.pow(it).toLong().sat - onAmountChange(valueSat) + index = it + val amountPicked = possibleValues[index.toInt() - 1] + onAmountChange(amountPicked) + onErrorStateChange(false) } catch (e: Exception) { - errorMessage = context.getString(R.string.validation_invalid_number) + errorMessage = context.getString(R.string.validation_invalid_amount) + onErrorStateChange(true) } }, - valueRange = minFeerateLog..maxFeerateLog, - steps = steps, + valueRange = 1.0f..possibleValues.size.toFloat(), + steps = possibleValues.size, enabled = enabled, colors = SliderDefaults.colors( activeTrackColor = MaterialTheme.colors.primary, @@ -82,4 +80,5 @@ fun SatoshiSlider( ErrorMessage(header = it, padding = PaddingValues(0.dp)) } } -} \ No newline at end of file +} + diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Settings.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Settings.kt index ddac4b04a..f16c78908 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Settings.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Settings.kt @@ -47,6 +47,19 @@ fun Setting(modifier: Modifier = Modifier, title: String, description: String?) } } +@Composable +fun Setting(modifier: Modifier = Modifier, title: String, content: @Composable ColumnScope.() -> Unit) { + Column( + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Text(title, style = MaterialTheme.typography.body2) + Spacer(modifier = Modifier.height(2.dp)) + content() + } +} + @Composable fun SettingWithCopy( title: String, @@ -56,7 +69,9 @@ fun SettingWithCopy( ) { val context = LocalContext.current Row { - Column(modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp).weight(1f)) { + Column(modifier = Modifier + .padding(start = 16.dp, top = 12.dp, bottom = 12.dp) + .weight(1f)) { Row { Text( text = title, diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeTopAndBottom.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeTopAndBottom.kt index a89a45047..9473fef24 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeTopAndBottom.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeTopAndBottom.kt @@ -23,7 +23,9 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface +import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,13 +38,17 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import fr.acinq.lightning.utils.Connection import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.business +import fr.acinq.phoenix.android.components.BorderButton import fr.acinq.phoenix.android.components.Button import fr.acinq.phoenix.android.components.FilledButton import fr.acinq.phoenix.android.components.VSeparator import fr.acinq.phoenix.android.components.openLink +import fr.acinq.phoenix.android.utils.borderColor import fr.acinq.phoenix.android.utils.isBadCertificate import fr.acinq.phoenix.android.utils.mutedBgColor import fr.acinq.phoenix.android.utils.negativeColor +import fr.acinq.phoenix.android.utils.orange import fr.acinq.phoenix.android.utils.positiveColor import fr.acinq.phoenix.android.utils.warningColor import fr.acinq.phoenix.managers.Connections @@ -54,8 +60,10 @@ fun TopBar( connections: Connections, electrumBlockheight: Int, onTorClick: () -> Unit, - isTorEnabled: Boolean? + isTorEnabled: Boolean?, + onRequestLiquidityClick: () -> Unit, ) { + val channelsState by business.peerManager.channelsFlow.collectAsState() val context = LocalContext.current val connectionsTransition = rememberInfiniteTransition(label = "animateConnectionsBadge") val connectionsButtonAlpha by connectionsTransition.animateFloat( @@ -116,16 +124,34 @@ fun TopBar( } } Spacer(modifier = Modifier.weight(1f)) - FilledButton( - text = stringResource(R.string.home__faq_button), - icon = R.drawable.ic_help_circle, - iconTint = MaterialTheme.colors.onSurface, - onClick = { openLink(context, "https://phoenix.acinq.co/faq") }, - textStyle = MaterialTheme.typography.button.copy(fontSize = 12.sp), - backgroundColor = MaterialTheme.colors.surface, - space = 8.dp, - padding = PaddingValues(8.dp), - ) + Row( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colors.surface), + verticalAlignment = Alignment.CenterVertically + ) { + if (!channelsState.isNullOrEmpty()) { + BorderButton( + text = stringResource(id = R.string.home_request_liquidity), + icon = R.drawable.ic_arrow_down_circle, + onClick = onRequestLiquidityClick, + iconTint = MaterialTheme.colors.onSurface, + textStyle = MaterialTheme.typography.button.copy(fontSize = 12.sp, color = MaterialTheme.colors.onSurface), + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 0.dp, bottomEnd = 0.dp, bottomStart = 16.dp), + space = 8.dp, + padding = PaddingValues(8.dp), + ) + } + Button( + text = stringResource(R.string.home__faq_button), + icon = R.drawable.ic_help_circle, + iconTint = MaterialTheme.colors.onSurface, + onClick = { openLink(context, "https://phoenix.acinq.co/faq") }, + textStyle = MaterialTheme.typography.button.copy(fontSize = 12.sp), + space = 8.dp, + padding = PaddingValues(8.dp), + ) + } } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeView.kt index 50e1c42a6..bc8dc24b5 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeView.kt @@ -77,6 +77,7 @@ fun HomeView( onElectrumClick: () -> Unit, onShowSwapInWallet: () -> Unit, onShowNotifications: () -> Unit, + onRequestLiquidityClick: () -> Unit, ) { val log = logger("HomeView") val context = LocalContext.current @@ -216,7 +217,8 @@ fun HomeView( connections = connections, electrumBlockheight = electrumMessages?.blockHeight ?: 0, isTorEnabled = torEnabledState.value, - onTorClick = onTorClick + onTorClick = onTorClick, + onRequestLiquidityClick = onRequestLiquidityClick, ) HomeBalance( modifier = Modifier.layoutId("balance"), diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/cpfp/CpfpView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/cpfp/CpfpView.kt index 8ddde23c3..9f43b9559 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/cpfp/CpfpView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/cpfp/CpfpView.kt @@ -16,10 +16,21 @@ package fr.acinq.phoenix.android.payments.cpfp -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.material.MaterialTheme import androidx.compose.material.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -29,12 +40,17 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.lightning.channel.ChannelCommand import fr.acinq.lightning.utils.sat import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.business -import fr.acinq.phoenix.android.components.* +import fr.acinq.phoenix.android.components.BorderButton +import fr.acinq.phoenix.android.components.FeerateSlider +import fr.acinq.phoenix.android.components.FilledButton +import fr.acinq.phoenix.android.components.ProgressView +import fr.acinq.phoenix.android.components.SplashLabelRow +import fr.acinq.phoenix.android.components.TextWithIcon import fr.acinq.phoenix.android.components.feedback.ErrorMessage +import fr.acinq.phoenix.android.payments.spliceFailureDetails import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.annotatedStringResource import fr.acinq.phoenix.android.utils.logger @@ -115,20 +131,8 @@ fun CpfpView( is CpfpState.Complete.Failed -> { ErrorMessage( header = stringResource(id = R.string.cpfp_failure_title), - details = when (state.failure) { - is ChannelCommand.Commitment.Splice.Response.Failure.AbortedByPeer -> stringResource(id = R.string.splice_error_aborted_by_peer, state.failure.reason) - is ChannelCommand.Commitment.Splice.Response.Failure.CannotCreateCommitTx -> stringResource(id = R.string.splice_error_cannot_create_commit) - is ChannelCommand.Commitment.Splice.Response.Failure.ChannelNotIdle -> stringResource(id = R.string.splice_error_channel_not_idle) - is ChannelCommand.Commitment.Splice.Response.Failure.Disconnected -> stringResource(id = R.string.splice_error_disconnected) - is ChannelCommand.Commitment.Splice.Response.Failure.FundingFailure -> stringResource(id = R.string.splice_error_funding_error, state.failure.reason.javaClass.simpleName) - is ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds -> stringResource(id = R.string.splice_error_insufficient_funds) - is ChannelCommand.Commitment.Splice.Response.Failure.CannotStartSession -> stringResource(id = R.string.splice_error_cannot_start_session) - is ChannelCommand.Commitment.Splice.Response.Failure.InteractiveTxSessionFailed -> stringResource(id = R.string.splice_error_interactive_session, state.failure.reason.javaClass.simpleName) - is ChannelCommand.Commitment.Splice.Response.Failure.InvalidSpliceOutPubKeyScript -> stringResource(id = R.string.splice_error_invalid_pubkey) - is ChannelCommand.Commitment.Splice.Response.Failure.SpliceAlreadyInProgress -> stringResource(id = R.string.splice_error_splice_in_progress) - }, + details = spliceFailureDetails(spliceFailure = state.failure), alignment = Alignment.CenterHorizontally, - padding = PaddingValues(0.dp) ) } is CpfpState.Error.NoChannels -> { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/cpfp/CpfpViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/cpfp/CpfpViewModel.kt index 9ca64ca8d..4d29679ea 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/cpfp/CpfpViewModel.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/cpfp/CpfpViewModel.kt @@ -27,6 +27,7 @@ import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.blockchain.fee.FeeratePerByte import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.ChannelCommand +import fr.acinq.lightning.utils.msat import fr.acinq.phoenix.managers.PeerManager import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers @@ -69,12 +70,18 @@ class CpfpViewModel(val peerManager: PeerManager) : ViewModel() { channelId = channelId, targetFeerate = userFeerate ) - if (res == null) { - state = CpfpState.Error.NoChannels - } else if (res.first <= userFeerate * 1.10) { - state = CpfpState.Error.FeerateTooLow(userFeerate = userFeerate, actualFeerate = res.first) - } else { - state = CpfpState.ReadyToExecute(userFeerate = userFeerate, actualFeerate = res.first, fee = res.second) + state = when (res) { + null -> CpfpState.Error.NoChannels + else -> { + val (actualFeerate, fees) = res + if (actualFeerate <= userFeerate * 1.10) { + CpfpState.Error.FeerateTooLow(userFeerate = userFeerate, actualFeerate = actualFeerate) + } else if (fees.serviceFee > 0.msat) { + throw IllegalArgumentException("service fee above 0") + } else { + CpfpState.ReadyToExecute(userFeerate = userFeerate, actualFeerate = res.first, fee = fees.miningFee) + } + } } } } 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 index d33f21229..1c7c9d4b8 100644 --- 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 @@ -47,6 +47,7 @@ import fr.acinq.lightning.blockchain.electrum.getConfirmations import fr.acinq.lightning.db.* 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 @@ -86,7 +87,11 @@ fun PaymentDetailsSplashView( topContent = { PaymentStatus(data.payment, fromEvent, onCpfpSuccess = onBackClick) } ) { AmountView( - amount = if (payment is OutgoingPayment) payment.amount - payment.fees else payment.amount, + 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) @@ -109,6 +114,9 @@ fun PaymentDetailsSplashView( PaymentDescriptionView(data = data, onMetadataDescriptionUpdate = onMetadataDescriptionUpdate) PaymentDestinationView(data = data) PaymentFeeView(payment = payment) + if (payment is InboundLiquidityOutgoingPayment) { + InboundLiquidityLeaseDetails(lease = payment.lease) + } data.payment.errorMessage()?.let { errorMessage -> Spacer(modifier = Modifier.height(8.dp)) @@ -285,6 +293,26 @@ private fun PaymentStatus( 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, + ) + } + } } } @@ -454,6 +482,7 @@ private fun PaymentDescriptionView( @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) { @@ -463,6 +492,7 @@ private fun PaymentDestinationView(data: WalletPaymentInfo) { 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) } ) } @@ -510,6 +540,22 @@ private fun PaymentFeeView(payment: WalletPayment) { 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() @@ -539,6 +585,14 @@ private fun PaymentFeeView(payment: WalletPayment) { } } +@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 EditPaymentDetails( initialDescription: String?, @@ -621,7 +675,7 @@ private fun ConfirmationView( confirmations?.let { conf -> if (conf == 0) { Card( - internalPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + internalPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), onClick = if (canBeBumped) { { showBumpTxDialog = true } } else null, backgroundColor = Color.Transparent, horizontalAlignment = Alignment.CenterHorizontally 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 5bc1ab132..fa4ecfac6 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 @@ -100,6 +100,7 @@ fun PaymentDetailsTechnicalView( is SpliceOutgoingPayment -> DetailsForSpliceOut(payment) is ChannelCloseOutgoingPayment -> DetailsForChannelClose(payment) is SpliceCpfpOutgoingPayment -> DetailsForCpfp(payment) + is InboundLiquidityOutgoingPayment -> DetailsForInboundLiquidity(payment) } } @@ -134,8 +135,8 @@ private fun HeaderForOutgoing( is LightningOutgoingPayment.Details.KeySend -> stringResource(R.string.paymentdetails_keysend) } is SpliceCpfpOutgoingPayment -> stringResource(id = R.string.paymentdetails_splice_cpfp_outgoing) + is InboundLiquidityOutgoingPayment -> stringResource(id = R.string.paymentdetails_inbound_liquidity) } - ) } @@ -143,6 +144,10 @@ private fun HeaderForOutgoing( TechnicalRow(label = stringResource(id = R.string.paymentdetails_status_label)) { Text( when (payment) { + is InboundLiquidityOutgoingPayment -> when (payment.lockedAt) { + null -> stringResource(R.string.paymentdetails_status_pending) + else -> stringResource(R.string.paymentdetails_status_success) + } is OnChainOutgoingPayment -> when (payment.confirmedAt) { null -> stringResource(R.string.paymentdetails_status_pending) else -> stringResource(R.string.paymentdetails_status_success) @@ -214,6 +219,30 @@ private fun AmountSection( rateThen: ExchangeRate.BitcoinPriceRate? ) { when (payment) { + is InboundLiquidityOutgoingPayment -> { + TechnicalRowAmount( + label = stringResource(id = R.string.paymentdetails_liquidity_amount_label), + amount = payment.lease.amount.toMilliSatoshi(), + rateThen = rateThen, + mSatDisplayPolicy = MSatDisplayPolicy.SHOW + ) + TechnicalRowAmount( + label = stringResource(id = R.string.paymentdetails_liquidity_miner_fee_label), + amount = payment.miningFees.toMilliSatoshi(), + rateThen = rateThen, + mSatDisplayPolicy = MSatDisplayPolicy.SHOW + ) + TechnicalRowAmount( + label = stringResource(id = R.string.paymentdetails_liquidity_service_fee_label), + amount = payment.lease.fees.serviceFee.toMilliSatoshi(), + rateThen = rateThen, + mSatDisplayPolicy = MSatDisplayPolicy.SHOW + ) + TechnicalRowSelectable( + label = stringResource(id = R.string.paymentdetails_liquidity_signature_label), + value = payment.lease.sellerSig.toHex(), + ) + } is OutgoingPayment -> { TechnicalRowAmount( label = stringResource(id = R.string.paymentdetails_amount_sent_label), @@ -333,6 +362,20 @@ private fun DetailsForCpfp( ) } +@Composable +private fun DetailsForInboundLiquidity( + payment: InboundLiquidityOutgoingPayment +) { + TechnicalRow( + label = stringResource(id = R.string.paymentdetails_tx_id_label), + content = { TransactionLinkButton(txId = payment.txId) } + ) + TechnicalRowSelectable( + label = stringResource(id = R.string.paymentdetails_channel_id_label), + value = payment.channelId.toHex(), + ) +} + @Composable private fun DetailsForSpliceOut( payment: SpliceOutgoingPayment 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 new file mode 100644 index 000000000..4d6020624 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityView.kt @@ -0,0 +1,306 @@ +/* + * 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.liquidity + +import androidx.compose.foundation.Image +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import fr.acinq.bitcoin.Satoshi +import fr.acinq.lightning.blockchain.fee.FeeratePerByte +import fr.acinq.lightning.blockchain.fee.FeeratePerKw +import fr.acinq.lightning.channel.ChannelCommand +import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.utils.sum +import fr.acinq.lightning.utils.toMilliSatoshi +import fr.acinq.phoenix.android.LocalBitcoinUnit +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.business +import fr.acinq.phoenix.android.components.AmountView +import fr.acinq.phoenix.android.components.AmountWithFiatBelow +import fr.acinq.phoenix.android.components.BackButtonWithBalance +import fr.acinq.phoenix.android.components.BorderButton +import fr.acinq.phoenix.android.components.FilledButton +import fr.acinq.phoenix.android.components.HSeparator +import fr.acinq.phoenix.android.components.IconPopup +import fr.acinq.phoenix.android.components.ProgressView +import fr.acinq.phoenix.android.components.SatoshiSlider +import fr.acinq.phoenix.android.components.SplashLabelRow +import fr.acinq.phoenix.android.components.SplashLayout +import fr.acinq.phoenix.android.components.feedback.ErrorMessage +import fr.acinq.phoenix.android.components.feedback.InfoMessage +import fr.acinq.phoenix.android.components.feedback.SuccessMessage +import fr.acinq.phoenix.android.payments.spliceFailureDetails +import fr.acinq.phoenix.android.utils.Converter.toPrettyString + +object LiquidityLimits { + val liquidityOptions = arrayOf( + 100_000.sat, + 250_000.sat, + 500_000.sat, + 750_000.sat, + 1_000_000.sat, + 1_500_000.sat, + 2_000_000.sat, + 3_000_000.sat, + 4_000_000.sat, + 5_000_000.sat, + 6_000_000.sat, + 8_000_000.sat, + 10_000_000.sat, + ) +} + +@Composable +fun RequestLiquidityView( + onBackClick: () -> Unit, +) { + val balance by business.balanceManager.balance.collectAsState(null) + val channelsState by business.peerManager.channelsFlow.collectAsState() + SplashLayout( + header = { BackButtonWithBalance(onBackClick = onBackClick, balance = balance) }, + topContent = { RequestLiquidityTopSection() }, + bottomContent = { + if (channelsState.isNullOrEmpty()) { + InfoMessage( + header = "No channels yet!", + details = "You first need funds in the wallet to use this feature." + ) + } else { + RequestLiquidityBottomSection() + } + }, + ) +} + +@Composable +private fun RequestLiquidityTopSection() { + val channelsState by business.peerManager.channelsFlow.collectAsState() + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(id = R.drawable.intro_btc), + contentDescription = null, + modifier = Modifier.size(96.dp), + ) + Row { + Text( + text = "Plan ahead your liquidity", + style = MaterialTheme.typography.h4, + ) + IconPopup( + popupMessage = stringResource(id = R.string.liquidityads_instructions), + popupLink = "More info" to "https://phoenix.acinq.co/faq", + colorAtRest = MaterialTheme.colors.primary, + ) + } + Spacer(modifier = Modifier.height(2.dp)) + val currentInbound = channelsState?.values?.map { it.availableForReceive }?.filterNotNull()?.sum() + currentInbound?.let { + Row { + Text( + text = stringResource(id = R.string.liquidityads_current_liquidity), + style = MaterialTheme.typography.subtitle2, + modifier = Modifier.alignByBaseline(), + ) + Spacer(modifier = Modifier.width(3.dp)) + AmountView( + amount = it, + forceUnit = LocalBitcoinUnit.current, + amountTextStyle = MaterialTheme.typography.subtitle2, + unitTextStyle = MaterialTheme.typography.subtitle2, + modifier = Modifier.alignByBaseline(), + ) + } + } + } +} + +@Composable +private fun RequestLiquidityBottomSection() { + + val peerManager = business.peerManager + val appConfigManager = business.appConfigurationManager + val mayDoPayments by business.peerManager.mayDoPayments.collectAsState() + + val vm = viewModel(factory = RequestLiquidityViewModel.Factory(peerManager, appConfigManager)) + var amount by remember { mutableStateOf(LiquidityLimits.liquidityOptions.first()) } + var isAmountError by remember { mutableStateOf(false) } + + if (vm.state.value !is RequestLiquidityState.Complete.Success) { + SplashLabelRow(label = stringResource(id = R.string.liquidityads_amount_label)) { + AmountWithFiatBelow( + amount = amount.toMilliSatoshi(), + amountTextStyle = MaterialTheme.typography.body2, + fiatTextStyle = MaterialTheme.typography.body1.copy(fontSize = 14.sp), + ) + SatoshiSlider( + modifier = Modifier + .widthIn(max = 130.dp) + .offset(x = (-5).dp, y = (-8).dp), + possibleValues = LiquidityLimits.liquidityOptions, + onAmountChange = { newAmount -> + if (vm.state.value !is RequestLiquidityState.Init && amount != newAmount) { + vm.state.value = RequestLiquidityState.Init + } + amount = newAmount + }, + onErrorStateChange = { isAmountError = it }, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + when (val state = vm.state.value) { + is RequestLiquidityState.Init -> { + BorderButton( + text = if (!mayDoPayments) stringResource(id = R.string.send_connecting_button) else stringResource(id = R.string.liquidityads_estimate_button), + icon = R.drawable.ic_inspect, + enabled = mayDoPayments && !isAmountError, + onClick = { vm.estimateFeeForInboundLiquidity(amount) }, + ) + } + is RequestLiquidityState.Estimating -> { + ProgressView(text = stringResource(id = R.string.liquidityads_estimating_spinner)) + } + is RequestLiquidityState.Estimation -> { + SplashLabelRow(label = "") { + HSeparator(width = 60.dp) + Spacer(modifier = Modifier.height(12.dp)) + } + LeaseEstimationView(amountRequested = amount, leaseFees = state.fees, actualFeerate = state.actualFeerate) + Spacer(modifier = Modifier.height(24.dp)) + FilledButton( + text = stringResource(id = R.string.liquidityads_request_button), + icon = R.drawable.ic_check_circle, + enabled = !isAmountError, + onClick = { + vm.requestInboundLiquidity( + amount = state.amount, + feerate = state.actualFeerate, + ) + }, + ) + } + is RequestLiquidityState.Requesting -> { + ProgressView(text = stringResource(id = R.string.liquidityads_requesting_spinner)) + } + is RequestLiquidityState.Complete.Success -> { + LeaseSuccessDetails(liquidityDetails = state.response) + } + is RequestLiquidityState.Complete.Failed -> { + ErrorMessage( + header = stringResource(id = R.string.liquidityads_error_header), + details = spliceFailureDetails(spliceFailure = state.response) + ) + } + is RequestLiquidityState.Error.NoChannelsAvailable -> { + ErrorMessage( + header = stringResource(id = R.string.liquidityads_error_header), + details = stringResource(id = R.string.liquidityads_error_channels_unavailable) + ) + } + is RequestLiquidityState.Error.Thrown -> { + ErrorMessage( + header = stringResource(id = R.string.liquidityads_error_header), + details = state.cause.localizedMessage + ) + } + } +} + +@Composable +private fun LeaseEstimationView( + amountRequested: Satoshi, + leaseFees: ChannelCommand.Commitment.Splice.Fees, + actualFeerate: FeeratePerKw +) { + SplashLabelRow( + label = stringResource(id = R.string.liquidityads_estimate_details_miner_fees), + helpMessage = stringResource(id = R.string.liquidityads_estimate_details_miner_fees_help, FeeratePerByte(actualFeerate).feerate.sat) + ) { + AmountWithFiatBelow(amount = leaseFees.miningFee.toMilliSatoshi(), amountTextStyle = MaterialTheme.typography.body2) + } + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow( + label = stringResource(id = R.string.liquidityads_estimate_details_service_fees), + helpMessage = stringResource(id = R.string.liquidityads_estimate_details_service_fees_help) + ) { + AmountWithFiatBelow(amount = leaseFees.serviceFee, amountTextStyle = MaterialTheme.typography.body2) + } + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow( + label = stringResource(id = R.string.liquidityads_estimate_details_duration), + helpMessage = stringResource(id = R.string.liquidityads_estimate_details_duration_help) + ) { + Column { + Text( + text = stringResource(id = R.string.liquidityads_estimate_details_duration_value), + style = MaterialTheme.typography.body2, + ) + } + } + + val totalFees = leaseFees.miningFee.toMilliSatoshi() + leaseFees.serviceFee + if (totalFees > amountRequested.toMilliSatoshi() * 0.25) { + SplashLabelRow( + label = "", + icon = R.drawable.ic_alert_triangle + ) { + Text( + text = stringResource(id = R.string.liquidityads_estimate_above_25), + style = MaterialTheme.typography.body1.copy(fontSize = 14.sp) + ) + } + } +} + +@Composable +private fun LeaseSuccessDetails(liquidityDetails: ChannelCommand.Commitment.Splice.Response.Created) { + SuccessMessage( + header = stringResource(id = R.string.liquidityads_success), + details = "You added ${liquidityDetails.liquidityLease?.amount?.toPrettyString(unit = LocalBitcoinUnit.current, withUnit = true)}" + ) +} \ No newline at end of file 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 new file mode 100644 index 000000000..a0beeaa1a --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityViewModel.kt @@ -0,0 +1,111 @@ +/* + * 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.liquidity + +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import fr.acinq.bitcoin.Satoshi +import fr.acinq.lightning.blockchain.fee.FeeratePerKw +import fr.acinq.lightning.channel.ChannelCommand +import fr.acinq.phoenix.managers.AppConfigurationManager +import fr.acinq.phoenix.managers.PeerManager +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory + + +sealed class RequestLiquidityState { + object Init: RequestLiquidityState() + object Estimating: RequestLiquidityState() + data class Estimation(val amount: Satoshi, val fees: ChannelCommand.Commitment.Splice.Fees, val actualFeerate: FeeratePerKw): RequestLiquidityState() + object Requesting: RequestLiquidityState() + sealed class Complete: RequestLiquidityState() { + abstract val response: ChannelCommand.Commitment.Splice.Response + data class Success(override val response: ChannelCommand.Commitment.Splice.Response.Created): Complete() + data class Failed(override val response: ChannelCommand.Commitment.Splice.Response.Failure): Complete() + } + sealed class Error: RequestLiquidityState() { + data class Thrown(val cause: Throwable): Error() + object NoChannelsAvailable: Error() + } +} + +class RequestLiquidityViewModel(val peerManager: PeerManager, val appConfigManager: AppConfigurationManager): ViewModel() { + + private val log = LoggerFactory.getLogger(this::class.java) + val state = mutableStateOf(RequestLiquidityState.Init) + + fun estimateFeeForInboundLiquidity(amount: Satoshi) { + if (state.value is RequestLiquidityState.Estimating || state.value is RequestLiquidityState.Requesting) return + state.value = RequestLiquidityState.Estimating + viewModelScope.launch(Dispatchers.Default + CoroutineExceptionHandler { _, e -> + log.error("failed to estimate fee for inbound liquidity: ", e) + state.value = RequestLiquidityState.Error.Thrown(e) + }) { + val peer = peerManager.getPeer() + val feerate = appConfigManager.mempoolFeerate.filterNotNull().first().economy + peer.estimateFeeForInboundLiquidity( + amount = amount, + targetFeerate = FeeratePerKw(feerate) + ).let { response -> + state.value = when (response) { + null -> RequestLiquidityState.Error.NoChannelsAvailable + else -> { + val (actualFeerate, fees) = response + RequestLiquidityState.Estimation(amount, fees, actualFeerate) + } + } + } + } + } + + fun requestInboundLiquidity(amount: Satoshi, feerate: FeeratePerKw) { + if (state.value is RequestLiquidityState.Requesting) return + state.value = RequestLiquidityState.Requesting + viewModelScope.launch(Dispatchers.Default + CoroutineExceptionHandler { _, e -> + log.error("failed to request inbound liquidity: ", e) + state.value = RequestLiquidityState.Error.Thrown(e) + }) { + val peer = peerManager.getPeer() + peer.requestInboundLiquidity( + amount = amount, + feerate = feerate + ).let { response -> + state.value = when (response) { + null -> RequestLiquidityState.Error.NoChannelsAvailable + is ChannelCommand.Commitment.Splice.Response.Failure -> RequestLiquidityState.Complete.Failed(response) + is ChannelCommand.Commitment.Splice.Response.Created -> RequestLiquidityState.Complete.Success(response) + } + } + } + } + + class Factory( + private val peerManager: PeerManager, + private val appConfigManager: AppConfigurationManager, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return RequestLiquidityViewModel(peerManager, appConfigManager) as T + } + } +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/spliceout/SpliceOutView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/spliceout/SpliceOutView.kt index 74d50fb53..d0235fd36 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/spliceout/SpliceOutView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/spliceout/SpliceOutView.kt @@ -199,18 +199,7 @@ fun SendSpliceOutView( Spacer(modifier = Modifier.height(24.dp)) ErrorMessage( header = stringResource(id = R.string.send_spliceout_error_failure), - details = when (state.result) { - is ChannelCommand.Commitment.Splice.Response.Failure.AbortedByPeer -> stringResource(id = R.string.splice_error_aborted_by_peer, state.result.reason) - is ChannelCommand.Commitment.Splice.Response.Failure.CannotCreateCommitTx -> stringResource(id = R.string.splice_error_cannot_create_commit) - is ChannelCommand.Commitment.Splice.Response.Failure.ChannelNotIdle -> stringResource(id = R.string.splice_error_channel_not_idle) - is ChannelCommand.Commitment.Splice.Response.Failure.Disconnected -> stringResource(id = R.string.splice_error_disconnected) - is ChannelCommand.Commitment.Splice.Response.Failure.FundingFailure -> stringResource(id = R.string.splice_error_funding_error, state.result.reason.javaClass.simpleName) - is ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds -> stringResource(id = R.string.splice_error_insufficient_funds) - is ChannelCommand.Commitment.Splice.Response.Failure.CannotStartSession -> stringResource(id = R.string.splice_error_cannot_start_session) - is ChannelCommand.Commitment.Splice.Response.Failure.InteractiveTxSessionFailed -> stringResource(id = R.string.splice_error_interactive_session, state.result.reason.javaClass.simpleName) - is ChannelCommand.Commitment.Splice.Response.Failure.InvalidSpliceOutPubKeyScript -> stringResource(id = R.string.splice_error_invalid_pubkey) - is ChannelCommand.Commitment.Splice.Response.Failure.SpliceAlreadyInProgress -> stringResource(id = R.string.splice_error_splice_in_progress) - }, + details = spliceFailureDetails(spliceFailure = state.result), alignment = Alignment.CenterHorizontally ) } @@ -236,3 +225,18 @@ private fun SpliceOutFeeSummaryView( } // TODO: show a warning if the fee is too large } + +@Composable +fun spliceFailureDetails(spliceFailure: ChannelCommand.Commitment.Splice.Response.Failure): String = when (spliceFailure) { + is ChannelCommand.Commitment.Splice.Response.Failure.AbortedByPeer -> stringResource(id = R.string.splice_error_aborted_by_peer, spliceFailure.reason) + is ChannelCommand.Commitment.Splice.Response.Failure.CannotCreateCommitTx -> stringResource(id = R.string.splice_error_cannot_create_commit) + is ChannelCommand.Commitment.Splice.Response.Failure.ChannelNotIdle -> stringResource(id = R.string.splice_error_channel_not_idle) + is ChannelCommand.Commitment.Splice.Response.Failure.Disconnected -> stringResource(id = R.string.splice_error_disconnected) + is ChannelCommand.Commitment.Splice.Response.Failure.FundingFailure -> stringResource(id = R.string.splice_error_funding_error, spliceFailure.reason.javaClass.simpleName) + is ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds -> stringResource(id = R.string.splice_error_insufficient_funds) + is ChannelCommand.Commitment.Splice.Response.Failure.CannotStartSession -> stringResource(id = R.string.splice_error_cannot_start_session) + is ChannelCommand.Commitment.Splice.Response.Failure.InteractiveTxSessionFailed -> stringResource(id = R.string.splice_error_interactive_session, spliceFailure.reason.javaClass.simpleName) + is ChannelCommand.Commitment.Splice.Response.Failure.InvalidSpliceOutPubKeyScript -> stringResource(id = R.string.splice_error_invalid_pubkey) + is ChannelCommand.Commitment.Splice.Response.Failure.SpliceAlreadyInProgress -> stringResource(id = R.string.splice_error_splice_in_progress) + is ChannelCommand.Commitment.Splice.Response.Failure.InvalidLiquidityAds -> stringResource(id = R.string.splice_error_invalid_liquidity_ads, spliceFailure.reason.details()) +} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/spliceout/SpliceOutViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/spliceout/SpliceOutViewModel.kt index 2b9419251..d13bb812b 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/spliceout/SpliceOutViewModel.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/spliceout/SpliceOutViewModel.kt @@ -28,6 +28,7 @@ import fr.acinq.lightning.NodeParams import fr.acinq.lightning.blockchain.fee.FeeratePerByte import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.ChannelCommand +import fr.acinq.lightning.utils.msat import fr.acinq.phoenix.managers.PeerManager import fr.acinq.phoenix.utils.Parser import kotlinx.coroutines.CoroutineExceptionHandler @@ -77,12 +78,17 @@ class SpliceOutViewModel(private val peerManager: PeerManager, private val chain targetFeerate = userFeerate, scriptPubKey = scriptPubKey ) - if (res != null) { - val (actualFeerate, fee) = res - log.info("received actual feerate=$actualFeerate from splice-out estimate fee") - state = SpliceOutState.ReadyToSend(amount, userFeerate, actualFeerate, estimatedFee = fee) - } else { - state = SpliceOutState.Error.NoChannels + state = when (res) { + null -> SpliceOutState.Error.NoChannels + else -> { + val (actualFeerate, fee) = res + log.info("received actual feerate=$actualFeerate from splice-out estimate fee") + if (fee.serviceFee > 0.msat) { + throw IllegalArgumentException("service fee above 0") + } else { + SpliceOutState.ReadyToSend(amount, userFeerate, actualFeerate, estimatedFee = fee.miningFee) + } + } } } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/channels/ChannelDetailsView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/channels/ChannelDetailsView.kt index 914a83824..621756444 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/channels/ChannelDetailsView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/channels/ChannelDetailsView.kt @@ -48,6 +48,7 @@ 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.db.IncomingPayment import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment import fr.acinq.lightning.db.SpliceOutgoingPayment @@ -55,6 +56,8 @@ import fr.acinq.lightning.db.WalletPayment import fr.acinq.phoenix.android.LocalBitcoinUnit import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.business +import fr.acinq.phoenix.android.components.AmountWithFiatBelow +import fr.acinq.phoenix.android.components.AmountWithFiatBeside import fr.acinq.phoenix.android.components.Button import fr.acinq.phoenix.android.components.Card import fr.acinq.phoenix.android.components.CardHeader @@ -131,7 +134,15 @@ private fun ChannelSummaryView( Setting(title = stringResource(id = R.string.channeldetails_state), description = channel.stateName) Setting( title = stringResource(id = R.string.channeldetails_spendable), - description = channel.localBalance?.toPrettyString(btcUnit, withUnit = true) ?: stringResource(id = R.string.utils_unknown) + content = { + channel.localBalance?.let { AmountWithFiatBeside(amount = it) } ?: Text(text = stringResource(id = R.string.utils_unknown)) + } + ) + Setting( + title = stringResource(id = R.string.channeldetails_receivable), + content = { + channel.availableForReceive?.let { AmountWithFiatBeside(amount = it) } ?: Text(text = stringResource(id = R.string.utils_unknown)) + } ) SettingInteractive( title = stringResource(id = R.string.channeldetails_json), @@ -214,6 +225,7 @@ private fun CommitmentDetailsView( payment is IncomingPayment && payment.origin is IncomingPayment.Origin.OnChain -> "swap-in" payment is SpliceOutgoingPayment -> "swap-out" payment is SpliceCpfpOutgoingPayment -> "cpfp" + payment is InboundLiquidityOutgoingPayment -> "inbound liquidity" else -> "other" }, onClick = { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/channels/ChannelsView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/channels/ChannelsView.kt index 511d9b889..a39e5a502 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/channels/ChannelsView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/channels/ChannelsView.kt @@ -23,9 +23,12 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme +import androidx.compose.material.ProgressIndicatorDefaults import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -36,17 +39,18 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import fr.acinq.bitcoin.ByteVector32 import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.utils.sum import fr.acinq.lightning.utils.toMilliSatoshi import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.business import fr.acinq.phoenix.android.components.* -import fr.acinq.phoenix.android.settings.walletinfo.BalanceRow import fr.acinq.phoenix.android.utils.logger import fr.acinq.phoenix.android.utils.mutedTextColor import fr.acinq.phoenix.android.utils.negativeColor @@ -64,6 +68,7 @@ fun ChannelsView( val channelsState by business.peerManager.channelsFlow.collectAsState() val balance by business.balanceManager.balance.collectAsState() + val inboundLiquidity = channelsState?.values?.map { it.availableForReceive }?.filterNotNull()?.sum() DefaultScreenLayout(isScrollable = false) { DefaultScreenHeader( @@ -88,7 +93,9 @@ fun ChannelsView( } ) if (!channelsState.isNullOrEmpty()) { - LightningBalanceView(balance = balance) + LightningBalanceView(balance = balance, + inboundLiquidity = inboundLiquidity, + ) } ChannelsList(channels = channelsState, onChannelClick = onChannelClick) } @@ -96,15 +103,58 @@ fun ChannelsView( @Composable private fun LightningBalanceView( - balance: MilliSatoshi? + balance: MilliSatoshi?, + inboundLiquidity: MilliSatoshi?, ) { CardHeader(text = stringResource(id = R.string.channelsview_balance)) - Card( - internalPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp) - ) { - BalanceRow(balance = balance) - Spacer(modifier = Modifier.height(8.dp)) - Text(text = stringResource(id = R.string.channelsview_balance_about), style = MaterialTheme.typography.subtitle2) + Card(internalPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp)) { + if (balance != null && inboundLiquidity != null) { + val balanceVsInbound = remember(balance, inboundLiquidity) { + (balance.msat.toFloat() / (balance.msat + inboundLiquidity.msat)) + .coerceIn(0.1f, 0.9f) // unreadable otherwise + } + Row(verticalAlignment = Alignment.CenterVertically) { + Surface( + shape = RoundedCornerShape(1.dp), + color = MaterialTheme.colors.primary, + modifier = Modifier.size(6.dp).offset(y = 2.dp) + ) {} + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringResource(id = R.string.channelsview_balance), + style = MaterialTheme.typography.body2, + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(id = R.string.channelsview_inbound), + style = MaterialTheme.typography.body2, + ) + Spacer(modifier = Modifier.width(6.dp)) + Surface( + shape = RoundedCornerShape(1.dp), + color = MaterialTheme.colors.primary.copy(alpha = ProgressIndicatorDefaults.IndicatorBackgroundOpacity), + modifier = Modifier.size(6.dp).offset(y = 2.dp) + ) {} + } + Spacer(modifier = Modifier.height(2.dp)) + LinearProgressIndicator( + progress = balanceVsInbound, + modifier = Modifier + .height(8.dp) + .fillMaxWidth(), + strokeCap = StrokeCap.Round, + ) + Spacer(modifier = Modifier.height(2.dp)) + Row { + Column { + AmountWithFiatBelow(amount = balance) + } + Spacer(modifier = Modifier.weight(1f)) + Column(horizontalAlignment = Alignment.End) { + AmountWithFiatBelow(amount = inboundLiquidity) + } + } + } } } 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 8a540a054..a7ffca2a8 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 @@ -34,9 +34,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.payment.LiquidityPolicy import fr.acinq.lightning.utils.sat @@ -50,10 +48,8 @@ import fr.acinq.phoenix.android.utils.annotatedStringResource import fr.acinq.phoenix.android.utils.datastore.UserPrefs import fr.acinq.phoenix.android.utils.logger import fr.acinq.phoenix.android.utils.negativeColor -import fr.acinq.phoenix.android.utils.orange import fr.acinq.phoenix.data.BitcoinUnit import fr.acinq.phoenix.data.MempoolFeerate -import kotlinx.coroutines.delay import kotlinx.coroutines.launch @OptIn(ExperimentalComposeUiApi::class) @@ -61,6 +57,7 @@ import kotlinx.coroutines.launch fun LiquidityPolicyView( onBackClick: () -> Unit, onAdvancedClick: () -> Unit, + onRequestLiquidityClick: () -> Unit, ) { val log = logger("LiquidityPolicyView") val context = LocalContext.current @@ -131,8 +128,8 @@ fun LiquidityPolicyView( } } + val keyboardManager = LocalSoftwareKeyboardController.current Card { - val keyboardManager = LocalSoftwareKeyboardController.current val skipAbsoluteFeeCheck = if (liquidityPolicyPrefs is LiquidityPolicy.Auto) liquidityPolicyPrefs.skipAbsoluteFeeCheck else false val newPolicy = when { isPolicyDisabled -> LiquidityPolicy.Disable @@ -160,6 +157,21 @@ fun LiquidityPolicyView( ) } + val channelsState by business.peerManager.channelsFlow.collectAsState() + if (!channelsState.isNullOrEmpty()) { + Card(modifier = Modifier.fillMaxWidth()) { + Button( + text = stringResource(id = R.string.liquiditypolicy_request_button), + icon = R.drawable.ic_restore, + onClick = { + keyboardManager?.hide() + onRequestLiquidityClick() + }, + modifier = Modifier.fillMaxWidth() + ) + } + } + } else { Card(modifier = Modifier.fillMaxWidth()) { ProgressView(text = stringResource(id = R.string.liquiditypolicy_loading)) @@ -235,7 +247,7 @@ private fun EditMaxFee( Spacer(modifier = Modifier.height(16.dp)) HSeparator(width = 50.dp) Spacer(modifier = Modifier.height(12.dp)) - when (val feerate = mempoolFeerate) { + when (mempoolFeerate) { null -> ProgressView(text = stringResource(id = R.string.liquiditypolicy_fees_estimation_loading), progressCircleSize = 16.dp, padding = PaddingValues(0.dp)) else -> { val fiatCurrency = LocalFiatCurrency.current @@ -245,8 +257,8 @@ private fun EditMaxFee( Text( text = annotatedStringResource( id = R.string.liquiditypolicy_fees_estimation, - feerate.swapEstimationFee(hasNoChannels).toPrettyString(BitcoinUnit.Sat, withUnit = true), - feerate.swapEstimationFee(hasNoChannels).toPrettyString(fiatCurrency, fiatRate, withUnit = true) + mempoolFeerate.swapEstimationFee(hasNoChannels).toPrettyString(BitcoinUnit.Sat, withUnit = true), + mempoolFeerate.swapEstimationFee(hasNoChannels).toPrettyString(fiatCurrency, fiatRate, withUnit = true) ) ) } 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 886c347cd..85b3c38c5 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 @@ -26,6 +26,7 @@ import fr.acinq.lightning.db.* import fr.acinq.lightning.utils.Connection 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 java.security.cert.CertificateException @@ -132,4 +133,5 @@ fun WalletPayment.smartDescription(context: Context): String? = when (this) { 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 diff --git a/phoenix-android/src/main/res/drawable/ic_inspect.xml b/phoenix-android/src/main/res/drawable/ic_inspect.xml new file mode 100644 index 000000000..6ec19fcdb --- /dev/null +++ b/phoenix-android/src/main/res/drawable/ic_inspect.xml @@ -0,0 +1,20 @@ + + + + diff --git a/phoenix-android/src/main/res/values/important_strings.xml b/phoenix-android/src/main/res/values/important_strings.xml index 360b3ed90..61905c3bd 100644 --- a/phoenix-android/src/main/res/values/important_strings.xml +++ b/phoenix-android/src/main/res/values/important_strings.xml @@ -210,6 +210,12 @@ Fees paid to the Bitcoin network miners to process the on-chain transaction. Service fees Fees paid for the creation of a new payment channel. This is not always required. + Service fees + Fees paid to the Bitcoin network miners to process the on-chain transaction. + Miner fees + Fees paid for the liquidity service. + Duration + 1 year @@ -294,6 +300,7 @@ Skip absolute fee check for Lightning When enabled, incoming Lightning payments will ignore the absolute max fee limit. Only the percentage check will apply.\n\nAttention: if the Bitcoin mempool feerate is high, incoming LN payments requiring an on-chain operation could be expensive. Save policy + Request inbound liquidity Phoenix allows you to receive payments on Bitcoin\'s blockchain (L1) and Bitcoin\'s Lightning layer (L2). @@ -307,6 +314,32 @@ Payments you receive on L2 can be received instantly and for zero fees. However, occasionally an L1 operation is also required in order to manage the L2 payment channel. This can be done automatically IF the miner fees adhere to your configured fee policy. + + + Request inbound liquidity + Inbound liquidity lets you avoid on-chain transactions fees for future payments received over Lightning.\n\nBy requesting more liquidity now, you can save fees later. + Current liquidity: + + Liquidity + Estimate liquidity cost + Estimating cost… + Miner fee + This fee goes to Bitcoin miners to process the transaction.\n\nThis fee is an estimation, using a feerate of %1$d sat/vbyte. + Service fee + This fee goes to the service providing the liquidity. + Duration + 1 year + The additional capacity will be preserved for that duration. + The total fee is more than 25% of the amount requested. + + Accept + Processing splice… + + Liquidity successfully added! + + Liquidity request has failed + Channels are not available. Try again later. + Unconfirmed - tap to accelerate diff --git a/phoenix-android/src/main/res/values/strings.xml b/phoenix-android/src/main/res/values/strings.xml index cba197fce..2bd0c9806 100644 --- a/phoenix-android/src/main/res/values/strings.xml +++ b/phoenix-android/src/main/res/values/strings.xml @@ -247,8 +247,12 @@ Payment channels Import channels + Overview Balance This is the aggregated balance of your active channels. It\'s what you can spend over Lightning. + Inbound liquidity + This is what your channels can receive over Lightning without having to pay on-chain fees. + Request liquidity Loading channel data… You don\'t have any channels yet.\n\nA new payment channel will be created automatically when needed. @@ -259,6 +263,7 @@ Channel id State Balance + Inbound liquidity Active commitments Inactive commitments @@ -300,6 +305,7 @@ Electrum certificate Connecting… Tor enabled + Liquidity @@ -334,6 +340,7 @@ Loading payment details… Could not find payment details + LIQUIDITY ADDED %1$s COMPLETE %1$s SENT %1$s Pending… @@ -375,6 +382,7 @@ Closing channel Migration from legacy app Bump transactions + +%1$s inbound liquidity Donation Swap-out to %1$s On-chain deposit @@ -398,6 +406,7 @@ Channel closing payment Outgoing splice payment Accelerate on-chain transactions + Request inbound liquidity Standard outgoing Lightning payment Standard incoming Lightning payment Swap-in Bitcoin deposit @@ -414,6 +423,9 @@ Transactions - #%1$s: + Liquidity amount + Liquidity request details + Closing channel Closing address Closing transaction @@ -777,6 +789,7 @@ Interactive tx session failed [%1$s] Invalid splice-out pubkey script A splice payment is already in progress + Invalid liquidity-ads request: [%1$s] From c90c76351e1d292ed128ee6e836c2d2d66171ba4 Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Thu, 14 Dec 2023 14:55:24 +0100 Subject: [PATCH 06/10] (android) Fix wording and add bucket icons/illus --- .../android/components/SplashLayout.kt | 7 +-- .../phoenix/android/home/HomeTopAndBottom.kt | 42 ++++++++---------- .../liquidity/RequestLiquidityView.kt | 13 +++--- .../settings/fees/LiquidityPolicyView.kt | 2 +- .../src/main/res/drawable/bucket_noto.png | Bin 0 -> 17763 bytes .../src/main/res/drawable/ic_bucket.xml | 30 +++++++++++++ .../src/main/res/values/important_strings.xml | 10 +++-- .../src/main/res/values/strings.xml | 2 +- 8 files changed, 68 insertions(+), 38 deletions(-) create mode 100644 phoenix-android/src/main/res/drawable/bucket_noto.png create mode 100644 phoenix-android/src/main/res/drawable/ic_bucket.xml 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 31990e71d..bce138f8d 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 @@ -129,15 +129,16 @@ fun SplashLabelRow( .alignByBaseline(), ) { Spacer(modifier = Modifier.weight(1f)) - if (helpMessage != null) { - IconPopup(modifier = Modifier.offset(y = (-3).dp), popupMessage = helpMessage, spaceLeft = 0.dp, spaceRight = 4.dp) - } + Text( text = label.uppercase(), style = MaterialTheme.typography.subtitle1.copy(fontSize = 12.sp, textAlign = TextAlign.End), maxLines = 2, overflow = TextOverflow.Ellipsis ) + if (helpMessage != null) { + IconPopup(modifier = Modifier.offset(y = (-3).dp), popupMessage = helpMessage, spaceLeft = 4.dp, spaceRight = 0.dp) + } if (icon != null) { Spacer(modifier = Modifier.width(4.dp)) Image( diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeTopAndBottom.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeTopAndBottom.kt index 9473fef24..c1e011169 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeTopAndBottom.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeTopAndBottom.kt @@ -124,34 +124,28 @@ fun TopBar( } } Spacer(modifier = Modifier.weight(1f)) - Row( - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colors.surface), - verticalAlignment = Alignment.CenterVertically - ) { - if (!channelsState.isNullOrEmpty()) { - BorderButton( - text = stringResource(id = R.string.home_request_liquidity), - icon = R.drawable.ic_arrow_down_circle, - onClick = onRequestLiquidityClick, - iconTint = MaterialTheme.colors.onSurface, - textStyle = MaterialTheme.typography.button.copy(fontSize = 12.sp, color = MaterialTheme.colors.onSurface), - shape = RoundedCornerShape(topStart = 16.dp, topEnd = 0.dp, bottomEnd = 0.dp, bottomStart = 16.dp), - space = 8.dp, - padding = PaddingValues(8.dp), - ) - } - Button( - text = stringResource(R.string.home__faq_button), - icon = R.drawable.ic_help_circle, - iconTint = MaterialTheme.colors.onSurface, - onClick = { openLink(context, "https://phoenix.acinq.co/faq") }, - textStyle = MaterialTheme.typography.button.copy(fontSize = 12.sp), + if (!channelsState.isNullOrEmpty()) { + BorderButton( + text = stringResource(id = R.string.home_request_liquidity), + icon = R.drawable.ic_bucket, + onClick = onRequestLiquidityClick, + textStyle = MaterialTheme.typography.button.copy(fontSize = 12.sp, color = MaterialTheme.colors.onSurface), + backgroundColor = MaterialTheme.colors.surface, space = 8.dp, padding = PaddingValues(8.dp), ) + Spacer(modifier = Modifier.width(4.dp)) } + FilledButton( + text = stringResource(R.string.home__faq_button), + icon = R.drawable.ic_help_circle, + iconTint = MaterialTheme.colors.onSurface, + onClick = { openLink(context, "https://phoenix.acinq.co/faq") }, + textStyle = MaterialTheme.typography.button.copy(fontSize = 12.sp), + backgroundColor = MaterialTheme.colors.surface, + space = 8.dp, + padding = PaddingValues(8.dp), + ) } } 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 4d6020624..c2b23a196 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 @@ -99,8 +99,8 @@ fun RequestLiquidityView( bottomContent = { if (channelsState.isNullOrEmpty()) { InfoMessage( - header = "No channels yet!", - details = "You first need funds in the wallet to use this feature." + header = stringResource(id = R.string.liquidityads_no_channels_header), + details = stringResource(id = R.string.liquidityads_no_channels_details) ) } else { RequestLiquidityBottomSection() @@ -120,18 +120,19 @@ private fun RequestLiquidityTopSection() { horizontalAlignment = Alignment.CenterHorizontally, ) { Image( - painter = painterResource(id = R.drawable.intro_btc), + painter = painterResource(id = R.drawable.bucket_noto), contentDescription = null, - modifier = Modifier.size(96.dp), + modifier = Modifier.size(82.dp), ) + Spacer(modifier = Modifier.height(20.dp)) Row { Text( - text = "Plan ahead your liquidity", + text = stringResource(id = R.string.liquidityads_header), style = MaterialTheme.typography.h4, ) IconPopup( popupMessage = stringResource(id = R.string.liquidityads_instructions), - popupLink = "More info" to "https://phoenix.acinq.co/faq", + popupLink = stringResource(id = R.string.liquidityads_faq_link) to "https://phoenix.acinq.co/faq#what-is-inbound-liquidity", colorAtRest = MaterialTheme.colors.primary, ) } 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 a7ffca2a8..4d1cb8d3a 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 @@ -162,7 +162,7 @@ fun LiquidityPolicyView( Card(modifier = Modifier.fillMaxWidth()) { Button( text = stringResource(id = R.string.liquiditypolicy_request_button), - icon = R.drawable.ic_restore, + icon = R.drawable.ic_bucket, onClick = { keyboardManager?.hide() onRequestLiquidityClick() diff --git a/phoenix-android/src/main/res/drawable/bucket_noto.png b/phoenix-android/src/main/res/drawable/bucket_noto.png new file mode 100644 index 0000000000000000000000000000000000000000..664bc5a0c3d55c13e0f34cff17c0fc455f670214 GIT binary patch literal 17763 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 UtJj6C`3f@3)78&qol`;+0E0zm)c^nh literal 0 HcmV?d00001 diff --git a/phoenix-android/src/main/res/drawable/ic_bucket.xml b/phoenix-android/src/main/res/drawable/ic_bucket.xml new file mode 100644 index 000000000..8709de5a1 --- /dev/null +++ b/phoenix-android/src/main/res/drawable/ic_bucket.xml @@ -0,0 +1,30 @@ + + + + + + diff --git a/phoenix-android/src/main/res/values/important_strings.xml b/phoenix-android/src/main/res/values/important_strings.xml index 61905c3bd..22184432f 100644 --- a/phoenix-android/src/main/res/values/important_strings.xml +++ b/phoenix-android/src/main/res/values/important_strings.xml @@ -211,9 +211,9 @@ Service fees Fees paid for the creation of a new payment channel. This is not always required. Service fees - Fees paid to the Bitcoin network miners to process the on-chain transaction. + Fees paid for the liquidity service. Miner fees - Fees paid for the liquidity service. + Fees paid to the Bitcoin network miners to process the on-chain transaction. Duration 1 year @@ -317,10 +317,14 @@ Request inbound liquidity + No channels yet! + You first need funds in the wallet to use this feature. + Plan ahead your liquidity Inbound liquidity lets you avoid on-chain transactions fees for future payments received over Lightning.\n\nBy requesting more liquidity now, you can save fees later. + More info Current liquidity: - Liquidity + Request liquidity Estimate liquidity cost Estimating cost… Miner fee diff --git a/phoenix-android/src/main/res/values/strings.xml b/phoenix-android/src/main/res/values/strings.xml index 2bd0c9806..65ccbfc1e 100644 --- a/phoenix-android/src/main/res/values/strings.xml +++ b/phoenix-android/src/main/res/values/strings.xml @@ -305,7 +305,7 @@ Electrum certificate Connecting… Tor enabled - Liquidity + Request liquidity From 21a1da35dea16342e4614d0db1281e7aeb7693f4 Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Thu, 14 Dec 2023 14:57:42 +0100 Subject: [PATCH 07/10] Use lightning-kmp 1.5.13 --- buildSrc/src/main/kotlin/Versions.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index acc6c529d..cf8b2c9ad 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -1,5 +1,5 @@ object Versions { - const val lightningKmp = "1.5.13-SNAPSHOT" + const val lightningKmp = "1.5.13" const val secp256k1 = "0.11.0" const val torMobile = "0.2.0" From 83c293b22b5a9226aa6f630d64ec72fc0357a856 Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Thu, 14 Dec 2023 15:25:11 +0100 Subject: [PATCH 08/10] Ignore cpfp/inbound for completed payments event --- .../fr.acinq.phoenix/managers/PaymentsManager.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentsManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentsManager.kt index 6f65a60e6..2e0b4b7e9 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentsManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentsManager.kt @@ -5,6 +5,8 @@ import fr.acinq.bitcoin.TxId import fr.acinq.bitcoin.byteVector32 import fr.acinq.lightning.blockchain.electrum.ElectrumClient import fr.acinq.lightning.blockchain.electrum.getConfirmations +import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment +import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment import fr.acinq.lightning.db.WalletPayment import fr.acinq.lightning.io.PaymentNotSent import fr.acinq.lightning.io.PaymentProgress @@ -91,9 +93,13 @@ class PaymentsManager( if (index > 0) { for (row in list) { val paymentInfo = fetcher.getPayment(row, WalletPaymentFetchOptions.None) - val completedAt = paymentInfo?.payment?.completedAt - if (completedAt != null && completedAt > appLaunchTimestamp) { - _lastCompletedPayment.value = paymentInfo.payment + if (paymentInfo?.payment is InboundLiquidityOutgoingPayment || paymentInfo?.payment is SpliceCpfpOutgoingPayment) { + // ignore cpfp/inbound + } else { + val completedAt = paymentInfo?.payment?.completedAt + if (completedAt != null && completedAt > appLaunchTimestamp) { + _lastCompletedPayment.value = paymentInfo.payment + } } break } From 42d25a128e7d825869e95a794ccbdc0bd6e1385d Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Thu, 14 Dec 2023 15:43:18 +0100 Subject: [PATCH 09/10] Fix wallet.state() for inbound liquidity --- .../fr.acinq.phoenix/utils/extensions/PaymentExtensions.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 07fccc441..ae2608593 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 @@ -31,7 +31,7 @@ fun WalletPayment.id(): String = when (this) { fun WalletPayment.state(): WalletPaymentState = when (this) { is InboundLiquidityOutgoingPayment -> when (lockedAt) { null -> WalletPaymentState.PendingOnChain - else -> WalletPaymentState.SuccessOffChain + else -> WalletPaymentState.SuccessOnChain } is OnChainOutgoingPayment -> when (confirmedAt) { null -> WalletPaymentState.PendingOnChain From e7c9c416667e387082c83295c151cadd6ad402bf Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Thu, 14 Dec 2023 15:58:31 +0100 Subject: [PATCH 10/10] Fix CSV export for inbound liquidity payments Also let the user copy the CSV in the Android app. --- .../android/payments/history/CsvExportView.kt | 21 ++++++++++++++----- .../payments/history/CsvExportViewModel.kt | 5 +++-- .../fr.acinq.phoenix/utils/CsvWriter.kt | 2 +- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/history/CsvExportView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/history/CsvExportView.kt index d5adb3717..be85e7a61 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/history/CsvExportView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/history/CsvExportView.kt @@ -20,6 +20,7 @@ import android.text.format.DateUtils import androidx.compose.foundation.layout.* import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -31,6 +32,7 @@ import fr.acinq.phoenix.android.business import fr.acinq.phoenix.android.components.* import fr.acinq.phoenix.android.components.feedback.ErrorMessage import fr.acinq.phoenix.android.utils.Converter.toBasicAbsoluteDateString +import fr.acinq.phoenix.android.utils.copyToClipboard import fr.acinq.phoenix.android.utils.logger import fr.acinq.phoenix.android.utils.positiveColor import fr.acinq.phoenix.android.utils.shareFile @@ -140,21 +142,30 @@ fun CsvExportView( iconTint = positiveColor, modifier = Modifier.padding(16.dp) ) + val subject = remember { + context.getString( + R.string.payments_export_share_subject, + startTimestamp?.toBasicAbsoluteDateString() ?: "N/A", endTimestamp.toBasicAbsoluteDateString() + ) + } + Button( + text = stringResource(id = R.string.btn_copy), + icon = R.drawable.ic_copy, + onClick = { copyToClipboard(context, data = state.content, dataLabel = subject) }, + modifier = Modifier.fillMaxWidth(), + ) Button( text = stringResource(R.string.payments_export_share_button), icon = R.drawable.ic_share, onClick = { shareFile( context, data = state.uri, - subject = context.getString( - R.string.payments_export_share_subject, - startTimestamp?.toBasicAbsoluteDateString() ?: "N/A", endTimestamp.toBasicAbsoluteDateString() - ), + subject = subject, chooserTitle = context.getString(R.string.payments_export_share_title), mimeType = "text/csv" ) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/history/CsvExportViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/history/CsvExportViewModel.kt index cbaff301b..dcefceea8 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/history/CsvExportViewModel.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/history/CsvExportViewModel.kt @@ -45,7 +45,7 @@ import java.io.FileWriter sealed class CsvExportState { object Init : CsvExportState() data class Generating(val exportedCount: Int) : CsvExportState() - data class Success(val paymentsCount: Int, val uri: Uri) : CsvExportState() + data class Success(val paymentsCount: Int, val uri: Uri, val content: String) : CsvExportState() object NoData : CsvExportState() data class Failed(val error: Throwable) : CsvExportState() } @@ -145,8 +145,9 @@ class CsvExportViewModel( } writer.close() val uri = FileProvider.getUriForFile(context, authority, file) + val content = rows.joinToString(separator = "") log.info("processed $paymentsCount payments CSV export") - state = CsvExportState.Success(paymentsCount, uri) + state = CsvExportState.Success(paymentsCount, uri, content) } } } 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 a230b7aa6..a9b8eb0e1 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 @@ -139,7 +139,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 inbound liquidity" + is InboundLiquidityOutgoingPayment -> "+${payment.lease.amount.sat} sat inbound liquidity" } row += ",${processField(details)}" }