diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 957cfda9f..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.12" + const val lightningKmp = "1.5.13" const val secp256k1 = "0.11.0" const val torMobile = "0.2.0" 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/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 a89a45047..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 @@ -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,6 +124,18 @@ fun TopBar( } } Spacer(modifier = Modifier.weight(1f)) + 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, 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/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-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..c2b23a196 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityView.kt @@ -0,0 +1,307 @@ +/* + * 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 = stringResource(id = R.string.liquidityads_no_channels_header), + details = stringResource(id = R.string.liquidityads_no_channels_details) + ) + } 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.bucket_noto), + contentDescription = null, + modifier = Modifier.size(82.dp), + ) + Spacer(modifier = Modifier.height(20.dp)) + Row { + Text( + text = stringResource(id = R.string.liquidityads_header), + style = MaterialTheme.typography.h4, + ) + IconPopup( + popupMessage = stringResource(id = R.string.liquidityads_instructions), + popupLink = stringResource(id = R.string.liquidityads_faq_link) to "https://phoenix.acinq.co/faq#what-is-inbound-liquidity", + 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/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/settings/fees/LiquidityPolicyView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/LiquidityPolicyView.kt index 8a540a054..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 @@ -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_bucket, + 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/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 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/bucket_noto.png b/phoenix-android/src/main/res/drawable/bucket_noto.png new file mode 100644 index 000000000..664bc5a0c Binary files /dev/null and b/phoenix-android/src/main/res/drawable/bucket_noto.png differ 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/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..22184432f 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 for the liquidity service. + Miner fees + Fees paid to the Bitcoin network miners to process the on-chain transaction. + 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,36 @@ 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 + 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: + + Request 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..65ccbfc1e 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 + Request 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] 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..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 @@ -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 = 271 * 2, // 2-inputs (wpkh)/ 0-change + 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..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 } @@ -159,6 +165,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..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,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} 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..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 @@ -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.SuccessOnChain + } 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,