diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 5e1c26379..c0b0ab3e5 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.7.3" + const val lightningKmp = "1.8.0" const val secp256k1 = "0.14.0" const val torMobile = "0.2.0" @@ -18,7 +18,7 @@ object Versions { const val lifecycle = "2.6.0" const val prefs = "1.2.0" const val datastore = "1.0.0" - const val compose = "1.6.2" + const val compose = "1.6.8" const val composeCompiler = "1.5.8" const val navCompose = "2.6.0" const val accompanist = "0.30.1" diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/BottomSheetDialog.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/BottomSheetDialog.kt new file mode 100644 index 000000000..e142c3d35 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/BottomSheetDialog.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BottomSheetDialog( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + skipPartiallyExpanded: Boolean = true, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + scrimAlpha: Float = 0.2f, + internalPadding: PaddingValues = PaddingValues(top = 0.dp, start = 20.dp, end = 20.dp, bottom = 64.dp), + content: @Composable ColumnScope.() -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded) + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = { + // executed when user click outside the sheet, and after sheet has been hidden thru state. + onDismiss() + }, + modifier = modifier, + containerColor = MaterialTheme.colors.surface, + contentColor = MaterialTheme.colors.onSurface, + scrimColor = MaterialTheme.colors.onBackground.copy(alpha = scrimAlpha), + ) { + Column( + horizontalAlignment = horizontalAlignment, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(internalPadding) + ) { + content() + } + } +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Buttons.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Buttons.kt index e5ad26176..abd4de427 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 @@ -336,11 +336,13 @@ fun Button( } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun Clickable( onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, + onLongClick: (() -> Unit)? = null, textStyle: TextStyle = MaterialTheme.typography.button, backgroundColor: Color = Color.Unspecified, // transparent by default! shape: Shape = RectangleShape, @@ -360,8 +362,11 @@ fun Clickable( elevation = 0.dp, modifier = modifier .clip(shape) - .clickable( + .combinedClickable( onClick = onClick, + onLongClick = onLongClick, + onLongClickLabel = null, + onDoubleClick = null, enabled = enabled, role = Role.Button, onClickLabel = clickDescription, @@ -422,7 +427,7 @@ fun AddressLinkButton( } @Composable -fun TransactionLinkButton( +fun InlineTransactionLink( modifier: Modifier = Modifier, txId: TxId, ) { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Dialogs.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Dialogs.kt index c8925eb76..23166c70b 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Dialogs.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Dialogs.kt @@ -179,7 +179,7 @@ fun RowScope.IconPopup( popupLink: Pair? = null, spaceLeft: Dp? = 8.dp, spaceRight: Dp? = null, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { var showPopup by remember { mutableStateOf(false) } spaceLeft?.let { Spacer(Modifier.requiredWidth(it)) } @@ -191,7 +191,7 @@ fun RowScope.IconPopup( padding = PaddingValues(iconPadding), modifier = modifier.requiredSize(iconSize), interactionSource = interactionSource, - onClick = { showPopup = true } + onClick = { showPopup = true }, ) if (showPopup) { PopupDialog(onDismiss = { showPopup = false }, message = popupMessage, button = popupLink?.let { (text, link) -> diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/SplashLayout.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/SplashLayout.kt index 7afac378b..d08ab6c1b 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/SplashLayout.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/SplashLayout.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.ButtonDefaults import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text @@ -34,7 +33,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -105,8 +103,8 @@ fun SplashLayout( } Column( modifier = Modifier - .widthIn(max = 500.dp) - .padding(horizontal = 24.dp), + .widthIn(max = 700.dp) + .padding(horizontal = 6.dp), horizontalAlignment = Alignment.CenterHorizontally ) { bottomContent() @@ -127,27 +125,30 @@ fun SplashLabelRow( ) { Row { Row( - modifier = Modifier.weight(1f).alignByBaseline(), + modifier = Modifier + .weight(1f) + .heightIn(min = 22.dp) + .alignByBaseline(), horizontalArrangement = Arrangement.End ) { + Spacer(modifier = Modifier.weight(1f)) + if (helpMessage != null) { + IconPopup(modifier = Modifier.offset(y = (-3).dp), popupMessage = helpMessage, popupLink = helpLink, spaceLeft = 0.dp, spaceRight = 5.dp) + } Text( text = label.uppercase(), - style = MaterialTheme.typography.subtitle1.copy(fontSize = 12.sp, textAlign = TextAlign.End), + style = MaterialTheme.typography.subtitle1.copy(fontSize = 12.sp), maxLines = 2, overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) ) - if (helpMessage != null) { - IconPopup(modifier = Modifier.offset(y = (-3).dp), popupMessage = helpMessage, popupLink = helpLink, spaceLeft = 4.dp, spaceRight = 0.dp) - } if (icon != null) { - Spacer(modifier = Modifier.width(4.dp)) + Spacer(modifier = Modifier.width(3.dp)) Image( painter = painterResource(id = icon), colorFilter = ColorFilter.tint(iconTint), contentDescription = null, modifier = Modifier - .size(ButtonDefaults.IconSize) + .size(17.dp) .offset(y = (-2).dp) ) } @@ -175,7 +176,7 @@ fun SplashClickableContent( .offset(x = (-8).dp), shape = RoundedCornerShape(12.dp) ) { - Column(modifier = Modifier.padding(8.dp)) { + Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) { content() } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/cpfp/CpfpViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/cpfp/CpfpViewModel.kt index f238df347..dcc28826e 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.channel.ChannelFundingResponse import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat import fr.acinq.phoenix.managers.PeerManager @@ -42,7 +43,7 @@ sealed class CpfpState { data class Executing(val actualFeerate: FeeratePerKw) : CpfpState() sealed class Complete : CpfpState() { object Success: Complete() - data class Failed(val failure: ChannelCommand.Commitment.Splice.Response.Failure): Complete() + data class Failed(val failure: ChannelFundingResponse.Failure): Complete() } sealed class Error: CpfpState() { data class Thrown(val e: Throwable): Error() @@ -102,11 +103,11 @@ class CpfpViewModel(val peerManager: PeerManager) : ViewModel() { log.info("failed to execute cpfp splice: assuming no channels") state = CpfpState.Error.NoChannels } - is ChannelCommand.Commitment.Splice.Response.Created -> { + is ChannelFundingResponse.Success -> { log.info("successfully executed cpfp splice: $res") state = CpfpState.Complete.Success } - is ChannelCommand.Commitment.Splice.Response.Failure -> { + is ChannelFundingResponse.Failure -> { log.info("failed to execute cpfp splice: $res") state = CpfpState.Complete.Failed(res) } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsSplashView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsSplashView.kt deleted file mode 100644 index f59485151..000000000 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsSplashView.kt +++ /dev/null @@ -1,880 +0,0 @@ -/* - * Copyright 2023 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.phoenix.android.payments.details - -import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi -import androidx.compose.animation.graphics.res.animatedVectorResource -import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter -import androidx.compose.animation.graphics.vector.AnimatedImageVector -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.PublicKey -import fr.acinq.bitcoin.TxId -import fr.acinq.bitcoin.utils.Either -import fr.acinq.lightning.blockchain.electrum.ElectrumConnectionStatus -import fr.acinq.lightning.db.* -import fr.acinq.lightning.payment.FinalFailure -import fr.acinq.lightning.payment.OutgoingPaymentFailure -import fr.acinq.lightning.utils.msat -import fr.acinq.lightning.utils.sum -import fr.acinq.lightning.wire.LiquidityAds -import fr.acinq.phoenix.android.LocalBitcoinUnit -import fr.acinq.phoenix.android.R -import fr.acinq.phoenix.android.business -import fr.acinq.phoenix.android.components.* -import fr.acinq.phoenix.android.components.contact.ContactCompactView -import fr.acinq.phoenix.android.components.contact.ContactOrOfferView -import fr.acinq.phoenix.android.components.contact.OfferContactState -import fr.acinq.phoenix.android.payments.cpfp.CpfpView -import fr.acinq.phoenix.android.utils.* -import fr.acinq.phoenix.android.utils.Converter.toPrettyString -import fr.acinq.phoenix.android.utils.Converter.toRelativeDateString -import fr.acinq.phoenix.data.LnurlPayMetadata -import fr.acinq.phoenix.data.WalletPaymentId -import fr.acinq.phoenix.data.WalletPaymentInfo -import fr.acinq.phoenix.data.lnurl.LnurlPay -import fr.acinq.phoenix.utils.extensions.WalletPaymentState -import fr.acinq.phoenix.utils.extensions.minDepthForFunding -import fr.acinq.phoenix.utils.extensions.incomingOfferMetadata -import fr.acinq.phoenix.utils.extensions.outgoingInvoiceRequest -import fr.acinq.phoenix.utils.extensions.state -import io.ktor.http.Url -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import javax.crypto.Cipher -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec - -@Composable -fun PaymentDetailsSplashView( - onBackClick: () -> Unit, - data: WalletPaymentInfo, - onDetailsClick: (WalletPaymentId) -> Unit, - onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit, - fromEvent: Boolean, -) { - val payment = data.payment - SplashLayout( - header = { DefaultScreenHeader(onBackClick = onBackClick) }, - topContent = { PaymentStatus(data.payment, fromEvent, onCpfpSuccess = onBackClick) } - ) { - AmountView( - amount = when (payment) { - is InboundLiquidityOutgoingPayment -> payment.amount - is OutgoingPayment -> payment.amount - payment.fees - is IncomingPayment -> payment.amount - }, - amountTextStyle = MaterialTheme.typography.body1.copy(fontSize = 30.sp), - separatorSpace = 4.dp, - prefix = stringResource(id = if (payment is OutgoingPayment) R.string.paymentline_prefix_sent else R.string.paymentline_prefix_received) - ) - - Spacer(modifier = Modifier.height(36.dp)) - PrimarySeparator( - height = 6.dp, - color = when (payment.state()) { - WalletPaymentState.Failure -> negativeColor - WalletPaymentState.SuccessOffChain, WalletPaymentState.SuccessOnChain -> positiveColor - else -> mutedBgColor - } - ) - Spacer(modifier = Modifier.height(36.dp)) - - if (data.payment is LightningOutgoingPayment && data.metadata.lnurl != null) { - LnurlPayInfoView(data.payment as LightningOutgoingPayment, data.metadata.lnurl!!) - } - - payment.incomingOfferMetadata()?.let { meta -> - meta.payerNote?.takeIf { it.isNotBlank() }?.let { - OfferPayerNote(payerNote = it) - Spacer(modifier = Modifier.height(8.dp)) - } - OfferSentBy(payerPubkey = meta.payerKey, !meta.payerNote.isNullOrBlank()) - } - - payment.outgoingInvoiceRequest()?.payerNote?.takeIf { it.isNotBlank() }?.let { - OfferPayerNote(payerNote = it) - } - - PaymentDescriptionView(data = data, onMetadataDescriptionUpdate = onMetadataDescriptionUpdate) - PaymentDestinationView(data = data) - PaymentFeeView(payment = payment) - if (payment is InboundLiquidityOutgoingPayment) { - InboundLiquidityLeaseDetails(lease = payment.lease) - } - - if (payment is LightningOutgoingPayment) { - (payment.status as? LightningOutgoingPayment.Status.Completed.Failed)?.let { status -> - PaymentErrorView(status = status, failedParts = payment.parts.map { it.status }.filterIsInstance()) - } - } - - Spacer(modifier = Modifier.height(48.dp)) - BorderButton( - text = stringResource(id = R.string.paymentdetails_details_button), - borderColor = borderColor, - textStyle = MaterialTheme.typography.caption, - icon = R.drawable.ic_tool, - iconTint = MaterialTheme.typography.caption.color, - onClick = { onDetailsClick(data.id()) }, - ) - } -} - -@Composable -private fun PaymentStatus( - payment: WalletPayment, - fromEvent: Boolean, - onCpfpSuccess: () -> Unit, -) { - val peerManager = business.peerManager - when (payment) { - is LightningOutgoingPayment -> when (payment.status) { - is LightningOutgoingPayment.Status.Pending -> PaymentStatusIcon( - message = { Text(text = stringResource(id = R.string.paymentdetails_status_sent_pending)) }, - imageResId = R.drawable.ic_payment_details_pending_static, - isAnimated = false, - color = mutedTextColor - ) - is LightningOutgoingPayment.Status.Completed.Failed -> PaymentStatusIcon( - message = { Text(text = annotatedStringResource(id = R.string.paymentdetails_status_sent_failed), textAlign = TextAlign.Center) }, - imageResId = R.drawable.ic_payment_details_failure_static, - isAnimated = false, - color = negativeColor - ) - is LightningOutgoingPayment.Status.Completed.Succeeded -> PaymentStatusIcon( - message = { - Text(text = annotatedStringResource(id = R.string.paymentdetails_status_sent_successful, payment.completedAt?.toRelativeDateString() ?: "")) - }, - imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, - isAnimated = fromEvent, - color = positiveColor, - ) - } - is ChannelCloseOutgoingPayment -> when (payment.confirmedAt) { - null -> { - PaymentStatusIcon( - message = null, - imageResId = R.drawable.ic_payment_details_pending_onchain_static, - isAnimated = false, - color = mutedTextColor, - ) - ConfirmationView(payment.txId, payment.channelId, isConfirmed = false, canBeBumped = false, onCpfpSuccess = onCpfpSuccess) - } - else -> { - PaymentStatusIcon( - message = { - Text(text = annotatedStringResource(id = R.string.paymentdetails_status_channelclose_confirmed, payment.completedAt?.toRelativeDateString() ?: "")) - }, - imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, - isAnimated = fromEvent, - color = positiveColor, - ) - ConfirmationView(payment.txId, payment.channelId, isConfirmed = true, canBeBumped = false, onCpfpSuccess) - } - } - is SpliceOutgoingPayment -> when (payment.confirmedAt) { - null -> { - PaymentStatusIcon( - message = null, - imageResId = R.drawable.ic_payment_details_pending_onchain_static, - isAnimated = false, - color = mutedTextColor, - ) - ConfirmationView(payment.txId, payment.channelId, isConfirmed = false, canBeBumped = true, onCpfpSuccess = onCpfpSuccess) - } - else -> { - PaymentStatusIcon( - message = { - Text(text = annotatedStringResource(id = R.string.paymentdetails_status_sent_successful, payment.completedAt!!.toRelativeDateString())) - }, - imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, - isAnimated = fromEvent, - color = positiveColor, - ) - ConfirmationView(payment.txId, payment.channelId, isConfirmed = true, canBeBumped = true, onCpfpSuccess = onCpfpSuccess) - } - } - is SpliceCpfpOutgoingPayment -> when (payment.confirmedAt) { - null -> { - PaymentStatusIcon( - message = null, - imageResId = R.drawable.ic_payment_details_pending_onchain_static, - isAnimated = false, - color = mutedTextColor, - ) - ConfirmationView(payment.txId, payment.channelId, isConfirmed = false, canBeBumped = true, onCpfpSuccess = onCpfpSuccess) - } - else -> { - PaymentStatusIcon( - message = { - Text(text = annotatedStringResource(id = R.string.paymentdetails_status_sent_successful, payment.completedAt!!.toRelativeDateString())) - }, - imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, - isAnimated = fromEvent, - color = positiveColor, - ) - ConfirmationView(payment.txId, payment.channelId, isConfirmed = true, canBeBumped = true, onCpfpSuccess = onCpfpSuccess) - } - } - is IncomingPayment -> { - val received = payment.received - when { - received == null -> { - PaymentStatusIcon( - message = { Text(text = stringResource(id = R.string.paymentdetails_status_received_pending)) }, - imageResId = R.drawable.ic_payment_details_pending_static, - isAnimated = false, - color = mutedTextColor - ) - } - received.receivedWith.isEmpty() -> { - PaymentStatusIcon( - message = { Text(text = stringResource(id = R.string.paymentdetails_status_received_paytoopen_pending)) }, - isAnimated = false, - imageResId = R.drawable.ic_clock, - color = mutedTextColor, - ) - } - received.receivedWith.any { it is IncomingPayment.ReceivedWith.OnChainIncomingPayment && it.lockedAt == null } -> { - PaymentStatusIcon( - message = { - Text(text = stringResource(id = R.string.paymentdetails_status_unconfirmed)) - }, - isAnimated = false, - imageResId = R.drawable.ic_clock, - color = mutedTextColor, - ) - } - payment.completedAt == null -> { - PaymentStatusIcon( - message = { - Text(text = stringResource(id = R.string.paymentdetails_status_received_pending)) - }, - imageResId = R.drawable.ic_payment_details_pending_static, - isAnimated = false, - color = mutedTextColor - ) - } - else -> { - PaymentStatusIcon( - message = { - Text(text = annotatedStringResource(id = R.string.paymentdetails_status_received_successful, payment.completedAt!!.toRelativeDateString())) - }, - imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, - isAnimated = fromEvent, - color = positiveColor, - ) - } - } - received?.receivedWith?.filterIsInstance()?.firstOrNull()?.let { - val nodeParams = business.nodeParamsManager.nodeParams.value - val channelMinDepth by produceState(initialValue = null, key1 = Unit) { - nodeParams?.let { params -> - val channelId = payment.received?.receivedWith?.filterIsInstance()?.firstOrNull()?.channelId - value = channelId?.let { peerManager.getChannelWithCommitments(it)?.minDepthForFunding(params) } - } - } - ConfirmationView(it.txId, it.channelId, isConfirmed = it.confirmedAt != null, canBeBumped = false, onCpfpSuccess = onCpfpSuccess, channelMinDepth) - } - } - is InboundLiquidityOutgoingPayment -> when (val lockedAt = payment.lockedAt) { - null -> { - PaymentStatusIcon( - message = null, - imageResId = R.drawable.ic_payment_details_pending_onchain_static, - isAnimated = false, - color = mutedTextColor, - ) - } - else -> { - PaymentStatusIcon( - message = { - Text(text = annotatedStringResource(id = R.string.paymentdetails_status_inbound_liquidity_success, lockedAt.toRelativeDateString())) - }, - imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, - isAnimated = fromEvent, - color = positiveColor, - ) - } - } - } -} - -@OptIn(ExperimentalAnimationGraphicsApi::class) -@Composable -private fun PaymentStatusIcon( - message: (@Composable ColumnScope.() -> Unit)?, - isAnimated: Boolean, - imageResId: Int, - color: Color, -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - val scope = rememberCoroutineScope() - var atEnd by remember { mutableStateOf(false) } - Image( - painter = if (isAnimated) { - rememberAnimatedVectorPainter(AnimatedImageVector.animatedVectorResource(imageResId), atEnd) - } else { - painterResource(id = imageResId) - }, - contentDescription = null, - colorFilter = ColorFilter.tint(color), - modifier = Modifier.size(80.dp) - ) - if (isAnimated) { - LaunchedEffect(key1 = Unit) { - scope.launch { - delay(150) - atEnd = true - } - } - } - message?.let { - Spacer(Modifier.height(16.dp)) - Column { it() } - } - } - -} - -@Composable -private fun LnurlPayInfoView(payment: LightningOutgoingPayment, metadata: LnurlPayMetadata) { - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_lnurlpay_service)) { - SelectionContainer { - Text(text = metadata.pay.callback.host) - } - } - metadata.successAction?.let { - LnurlSuccessAction(payment = payment, action = it) - } -} - -@Composable -private fun LnurlSuccessAction(payment: LightningOutgoingPayment, action: LnurlPay.Invoice.SuccessAction) { - Spacer(modifier = Modifier.height(8.dp)) - when (action) { - is LnurlPay.Invoice.SuccessAction.Message -> { - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_lnurlpay_action_message_label)) { - SelectionContainer { - Text(text = action.message) - } - } - } - is LnurlPay.Invoice.SuccessAction.Url -> { - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_lnurlpay_action_url_label)) { - Text(text = action.description) - WebLink(text = stringResource(id = R.string.paymentdetails_lnurlpay_action_url_button), url = action.url.toString()) - } - } - is LnurlPay.Invoice.SuccessAction.Aes -> { - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_lnurlpay_action_aes_label)) { - val status = payment.status - if (status is LightningOutgoingPayment.Status.Completed.Succeeded.OffChain) { - val deciphered by produceState(initialValue = null) { - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(status.preimage.toByteArray(), "AES"), IvParameterSpec(action.iv.toByteArray())) - value = String(cipher.doFinal(action.ciphertext.toByteArray()), Charsets.UTF_8) - } - Text(text = action.description) - when (deciphered) { - null -> ProgressView(text = stringResource(id = R.string.paymentdetails_lnurlpay_action_aes_decrypting), padding = PaddingValues(0.dp)) - else -> { - val url = try { - Url(deciphered!!) - } catch (e: Exception) { - null - } - if (url != null) { - WebLink(text = stringResource(id = R.string.paymentdetails_lnurlpay_action_url_button), url = url.toString()) - } else { - SelectionContainer { - Text(text = deciphered!!) - } - } - } - } - } else { - Text(text = stringResource(id = R.string.paymentdetails_lnurlpay_action_aes_decrypting)) - } - } - } - } -} - -@Composable -private fun OfferPayerNote(payerNote: String) { - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_offer_note_label)) { - Text(text = payerNote) - } -} - -@Composable -private fun OfferSentBy(payerPubkey: PublicKey?, hasPayerNote: Boolean) { - val contactsManager = business.contactsManager - val contactState = remember { mutableStateOf(OfferContactState.Init) } - LaunchedEffect(Unit) { - contactState.value = payerPubkey?.let { - contactsManager.getContactForPayerPubkey(it) - }?.let { OfferContactState.Found(it) } ?: OfferContactState.NotFound - } - - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_offer_sender_label)) { - when (val res = contactState.value) { - is OfferContactState.Init -> Text(text = stringResource(id = R.string.utils_loading_data)) - is OfferContactState.NotFound -> { - Text(text = stringResource(id = R.string.paymentdetails_offer_sender_unknown)) - if (hasPayerNote) { - Spacer(modifier = Modifier.height(4.dp)) - Text(text = stringResource(id = R.string.paymentdetails_offer_sender_unknown_details), style = MaterialTheme.typography.subtitle2) - } - } - is OfferContactState.Found -> { - ContactCompactView( - contact = res.contact, - currentOffer = null, - onContactChange = { contactState.value = if (it == null) OfferContactState.NotFound else OfferContactState.Found(it) }, - ) - } - } - } -} - -@Composable -private fun PaymentDescriptionView( - data: WalletPaymentInfo, - onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit, -) { - var showEditDescriptionDialog by remember { mutableStateOf(false) } - - val peer by business.peerManager.peerState.collectAsState() - val paymentDesc = data.metadata.lnurl?.description ?: data.payment.smartDescription(LocalContext.current) - val customDesc = remember(data) { data.metadata.userDescription?.takeIf { it.isNotBlank() } } - - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_desc_label)) { - val isLegacyMigration = data.isLegacyMigration(peer) - val finalDesc = when (isLegacyMigration) { - null -> stringResource(id = R.string.paymentdetails_desc_closing_channel) // not sure yet, but we still know it's a closing - true -> stringResource(id = R.string.paymentdetails_desc_legacy_migration) - false -> paymentDesc ?: customDesc - } - - if (isLegacyMigration == false) { - SplashClickableContent(onClick = { showEditDescriptionDialog = true }) { - Text( - text = finalDesc ?: stringResource(id = R.string.paymentdetails_no_description), - style = if (finalDesc == null) MaterialTheme.typography.caption.copy(fontStyle = FontStyle.Italic) else MaterialTheme.typography.body1 - ) - Spacer(modifier = Modifier.height(8.dp)) - if (paymentDesc != null && customDesc != null) { - HSeparator(width = 50.dp) - Spacer(modifier = Modifier.height(8.dp)) - Text(text = customDesc, style = MaterialTheme.typography.body1.copy(fontStyle = FontStyle.Italic)) - Spacer(modifier = Modifier.height(8.dp)) - } - TextWithIcon( - text = stringResource( - id = when (customDesc) { - null -> R.string.paymentdetails_attach_desc_button - else -> R.string.paymentdetails_edit_desc_button - } - ), - textStyle = MaterialTheme.typography.subtitle2, - icon = R.drawable.ic_edit, - iconTint = MaterialTheme.typography.subtitle2.color, - space = 6.dp, - ) - } - } - } - - if (showEditDescriptionDialog) { - CustomNoteDialog( - initialDescription = data.metadata.userDescription, - onConfirm = { - onMetadataDescriptionUpdate(data.id(), it?.trim()?.takeIf { it.isNotBlank() }) - showEditDescriptionDialog = false - }, - onDismiss = { showEditDescriptionDialog = false } - ) - } -} - -@Composable -private fun PaymentDestinationView(data: WalletPaymentInfo) { - when (val payment = data.payment) { - is InboundLiquidityOutgoingPayment -> {} - is OnChainOutgoingPayment -> { - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_destination_label), icon = R.drawable.ic_chain) { - SelectionContainer { - Text( - text = when (payment) { - is SpliceOutgoingPayment -> payment.address - is ChannelCloseOutgoingPayment -> payment.address - is SpliceCpfpOutgoingPayment -> stringResource(id = R.string.paymentdetails_destination_cpfp_value) - else -> stringResource(id = R.string.utils_unknown) - } - ) - } - } - } - is LightningOutgoingPayment -> { - val lnId = data.metadata.lnurl?.pay?.metadata?.lnid?.takeIf { it.isNotBlank() } - if (lnId != null) { - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_destination_label), icon = R.drawable.ic_zap) { - SelectionContainer { - Text(text = lnId) - } - } - } - - val details = payment.details - if (details is LightningOutgoingPayment.Details.Blinded) { - val offer = details.paymentRequest.invoiceRequest.offer - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_destination_label)) { - ContactOrOfferView(offer = offer) - } - } - } - else -> Unit - } -} - -@Composable -private fun PaymentFeeView(payment: WalletPayment) { - val btcUnit = LocalBitcoinUnit.current - when { - payment is LightningOutgoingPayment && (payment.state() == WalletPaymentState.SuccessOffChain) -> { - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_fees_label)) { - Text(text = payment.fees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) - } - } - payment is SpliceOutgoingPayment -> { - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_fees_label)) { - Text(text = payment.fees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) - } - } - payment is ChannelCloseOutgoingPayment -> { - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_fees_label)) { - Text(text = payment.fees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) - } - } - payment is SpliceCpfpOutgoingPayment -> { - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_fees_label)) { - Text(text = payment.fees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) - } - } - payment is InboundLiquidityOutgoingPayment -> { - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow( - label = stringResource(id = R.string.paymentdetails_liquidity_miner_fee_label), - helpMessage = stringResource(id = R.string.paymentdetails_liquidity_miner_fee_help) - ) { - Text(text = payment.miningFees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) - } - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow( - label = stringResource(id = R.string.paymentdetails_liquidity_service_fee_label), - helpMessage = stringResource(id = R.string.paymentdetails_liquidity_service_fee_help) - ) { - Text(text = payment.lease.fees.serviceFee.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) - } - } - payment is IncomingPayment -> { - val receivedWithNewChannel = payment.received?.receivedWith?.filterIsInstance() ?: emptyList() - val receivedWithSpliceIn = payment.received?.receivedWith?.filterIsInstance() ?: emptyList() - if ((receivedWithNewChannel + receivedWithSpliceIn).isNotEmpty()) { - val serviceFee = receivedWithNewChannel.map { it.serviceFee }.sum() + receivedWithSpliceIn.map { it.serviceFee }.sum() - val fundingFee = receivedWithNewChannel.map { it.miningFee }.sum() + receivedWithSpliceIn.map { it.miningFee }.sum() - Spacer(modifier = Modifier.height(8.dp)) - if (serviceFee > 0.msat) { - SplashLabelRow( - label = stringResource(id = R.string.paymentdetails_service_fees_label), - helpMessage = stringResource(R.string.paymentdetails_service_fees_desc) - ) { - Text(text = serviceFee.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW)) - } - Spacer(modifier = Modifier.height(8.dp)) - } - - SplashLabelRow( - label = stringResource(id = R.string.paymentdetails_funding_fees_label), - helpMessage = stringResource(R.string.paymentdetails_funding_fees_desc) - ) { - Text(text = fundingFee.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.HIDE)) - } - } - } - else -> {} - } -} - -@Composable -private fun InboundLiquidityLeaseDetails(lease: LiquidityAds.Lease) { - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_liquidity_lease_duration_label)) { - Text(text = stringResource(id = R.string.paymentdetails_liquidity_lease_duration_value)) - } -} - -@Composable -private fun PaymentErrorView(status: LightningOutgoingPayment.Status.Completed.Failed, failedParts: List) { - val failure = remember(status, failedParts) { OutgoingPaymentFailure(status.reason, failedParts) } - translatePaymentError(failure).let { - Spacer(modifier = Modifier.height(8.dp)) - SplashLabelRow(label = stringResource(id = R.string.paymentdetails_error_label)) { - Text(text = it) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun CustomNoteDialog( - initialDescription: String?, - onConfirm: (String?) -> Unit, - onDismiss: () -> Unit -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) - var description by rememberSaveable { mutableStateOf(initialDescription) } - - ModalBottomSheet( - sheetState = sheetState, - onDismissRequest = onDismiss, - containerColor = MaterialTheme.colors.surface, - contentColor = MaterialTheme.colors.onSurface, - scrimColor = MaterialTheme.colors.onBackground.copy(alpha = 0.1f), - ) { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(top = 0.dp, start = 24.dp, end = 24.dp, bottom = 70.dp), - ) { - Text(text = stringResource(id = R.string.paymentdetails_edit_dialog_title), style = MaterialTheme.typography.body2) - Spacer(modifier = Modifier.height(16.dp)) - TextInput( - modifier = Modifier.fillMaxWidth(), - text = description ?: "", - onTextChange = { description = it.takeIf { it.isNotBlank() } }, - minLines = 2, - maxLines = 6, - maxChars = 280, - staticLabel = stringResource(id = R.string.paymentdetails_edit_dialog_input_label) - ) - Spacer(modifier = Modifier.height(24.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - Button(onClick = onDismiss, text = stringResource(id = R.string.btn_cancel), shape = CircleShape) - Button( - onClick = { onConfirm(description) }, - text = stringResource(id = R.string.btn_save), - icon = R.drawable.ic_check, - enabled = description != initialDescription, - space = 8.dp, - shape = CircleShape - ) - } - } - } -} - -@Composable -private fun ConfirmationView( - txId: TxId, - channelId: ByteVector32, - isConfirmed: Boolean, - canBeBumped: Boolean, - onCpfpSuccess: () -> Unit, - minDepth: Int? = null, // sometimes we know how many confirmations are needed -) { - val txUrl = txUrl(txId = txId) - val context = LocalContext.current - val electrumClient = business.electrumClient - var showBumpTxDialog by remember { mutableStateOf(false) } - - if (isConfirmed) { - FilledButton( - text = stringResource(id = R.string.paymentdetails_status_confirmed), - icon = R.drawable.ic_chain, - backgroundColor = Color.Transparent, - padding = PaddingValues(8.dp), - textStyle = MaterialTheme.typography.button.copy(fontSize = 14.sp), - iconTint = MaterialTheme.colors.primary, - space = 6.dp, - onClick = { openLink(context, txUrl) } - ) - } else { - - suspend fun getConfirmations(): Int { - val confirmations = electrumClient.getConfirmations(txId) - return confirmations ?: run { - delay(5_000) - getConfirmations() - } - } - - val confirmations by produceState(initialValue = null) { - electrumClient.connectionStatus.filterIsInstance().first() - val confirmations = getConfirmations() - value = confirmations - } - confirmations?.let { conf -> - if (conf == 0) { - Card( - internalPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), - onClick = if (canBeBumped) { - { showBumpTxDialog = true } - } else null, - backgroundColor = Color.Transparent, - horizontalAlignment = Alignment.CenterHorizontally - ) { - TextWithIcon( - text = stringResource(R.string.paymentdetails_status_unconfirmed_zero), - icon = if (canBeBumped) R.drawable.ic_rocket else R.drawable.ic_clock, - textStyle = MaterialTheme.typography.button.copy(fontSize = 14.sp, color = MaterialTheme.colors.primary), - iconTint = MaterialTheme.colors.primary - ) - - if (canBeBumped) { - Text( - text = stringResource(id = R.string.paymentdetails_status_unconfirmed_zero_bump), - style = MaterialTheme.typography.button.copy(fontSize = 14.sp, color = MaterialTheme.colors.primary, fontWeight = FontWeight.Bold), - ) - } - } - } else { - FilledButton( - text = when (minDepth) { - null -> stringResource(R.string.paymentdetails_status_unconfirmed_default, conf) - else -> stringResource(R.string.paymentdetails_status_unconfirmed_with_depth, conf, minDepth) - }, - icon = R.drawable.ic_chain, - onClick = { openLink(context, txUrl) }, - backgroundColor = Color.Transparent, - padding = PaddingValues(8.dp), - textStyle = MaterialTheme.typography.button.copy(fontSize = 14.sp), - iconTint = MaterialTheme.colors.primary, - space = 6.dp, - ) - } - - if (conf == 0 && showBumpTxDialog) { - BumpTransactionDialog(channelId = channelId, onSuccess = onCpfpSuccess, onDismiss = { showBumpTxDialog = false }) - } - } ?: ProgressView( - text = stringResource(id = R.string.paymentdetails_status_unconfirmed_fetching), - textStyle = MaterialTheme.typography.body1.copy(fontSize = 14.sp), - padding = PaddingValues(8.dp), - progressCircleSize = 16.dp, - ) - } -} - -@Composable -private fun BumpTransactionDialog( - channelId: ByteVector32, - onSuccess: () -> Unit, - onDismiss: () -> Unit, -) { - Dialog( - onDismiss = onDismiss, - title = stringResource(id = R.string.cpfp_title), - buttons = null, - ) { - CpfpView(channelId = channelId, onSuccess = onSuccess) - } -} - -@Composable -fun translatePaymentError(paymentFailure: OutgoingPaymentFailure): String { - val context = LocalContext.current - val errorMessage = remember(key1 = paymentFailure) { - when (val result = paymentFailure.explain()) { - is Either.Left -> { - when (val partFailure = result.value) { - is LightningOutgoingPayment.Part.Status.Failure.Uninterpretable -> partFailure.message - LightningOutgoingPayment.Part.Status.Failure.ChannelIsClosing -> context.getString(R.string.outgoing_failuremessage_channel_closing) - LightningOutgoingPayment.Part.Status.Failure.ChannelIsSplicing -> context.getString(R.string.outgoing_failuremessage_channel_splicing) - LightningOutgoingPayment.Part.Status.Failure.NotEnoughFees -> context.getString(R.string.outgoing_failuremessage_not_enough_fee) - LightningOutgoingPayment.Part.Status.Failure.NotEnoughFunds -> context.getString(R.string.outgoing_failuremessage_not_enough_balance) - LightningOutgoingPayment.Part.Status.Failure.PaymentAmountTooBig -> context.getString(R.string.outgoing_failuremessage_too_big) - LightningOutgoingPayment.Part.Status.Failure.PaymentAmountTooSmall -> context.getString(R.string.outgoing_failuremessage_too_small) - LightningOutgoingPayment.Part.Status.Failure.PaymentExpiryTooBig -> context.getString(R.string.outgoing_failuremessage_expiry_too_big) - LightningOutgoingPayment.Part.Status.Failure.RecipientRejectedPayment -> context.getString(R.string.outgoing_failuremessage_rejected_by_recipient) - LightningOutgoingPayment.Part.Status.Failure.RecipientIsOffline -> context.getString(R.string.outgoing_failuremessage_recipient_offline) - LightningOutgoingPayment.Part.Status.Failure.RecipientLiquidityIssue -> context.getString(R.string.outgoing_failuremessage_not_enough_liquidity) - LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure -> context.getString(R.string.outgoing_failuremessage_temporary_failure) - LightningOutgoingPayment.Part.Status.Failure.TooManyPendingPayments -> context.getString(R.string.outgoing_failuremessage_too_many_pending) - } - } - is Either.Right -> { - when (result.value) { - FinalFailure.InvalidPaymentId -> context.getString(R.string.outgoing_failuremessage_invalid_id) - FinalFailure.AlreadyPaid -> context.getString(R.string.outgoing_failuremessage_alreadypaid) - FinalFailure.ChannelClosing -> context.getString(R.string.outgoing_failuremessage_channel_closing) - FinalFailure.ChannelNotConnected -> context.getString(R.string.outgoing_failuremessage_not_connected) - FinalFailure.ChannelOpening -> context.getString(R.string.outgoing_failuremessage_channel_opening) - FinalFailure.FeaturesNotSupported -> context.getString(R.string.outgoing_failuremessage_unsupported_features) - FinalFailure.InsufficientBalance -> context.getString(R.string.outgoing_failuremessage_not_enough_balance) - FinalFailure.InvalidPaymentAmount -> context.getString(R.string.outgoing_failuremessage_invalid_amount) - FinalFailure.NoAvailableChannels -> context.getString(R.string.outgoing_failuremessage_no_available_channels) - FinalFailure.RecipientUnreachable -> context.getString(R.string.outgoing_failuremessage_noroutefound) - FinalFailure.RetryExhausted -> context.getString(R.string.outgoing_failuremessage_noroutefound) - FinalFailure.UnknownError -> context.getString(R.string.outgoing_failuremessage_unknown) - FinalFailure.WalletRestarted -> context.getString(R.string.outgoing_failuremessage_restarted) - } - } - } - } - return errorMessage -} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsTechnicalView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsTechnicalView.kt index d08bc4d50..67c27f0f1 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 @@ -33,6 +33,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.TxId import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.db.* import fr.acinq.lightning.payment.Bolt11Invoice @@ -42,24 +43,33 @@ import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sum import fr.acinq.lightning.utils.toMilliSatoshi +import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.phoenix.android.LocalBitcoinUnit import fr.acinq.phoenix.android.LocalFiatCurrency import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.Screen import fr.acinq.phoenix.android.business import fr.acinq.phoenix.android.components.AmountView import fr.acinq.phoenix.android.components.Card import fr.acinq.phoenix.android.components.CardHeader import fr.acinq.phoenix.android.components.Clickable import fr.acinq.phoenix.android.components.TextWithIcon -import fr.acinq.phoenix.android.components.TransactionLinkButton +import fr.acinq.phoenix.android.components.InlineTransactionLink +import fr.acinq.phoenix.android.components.openLink +import fr.acinq.phoenix.android.components.txUrl import fr.acinq.phoenix.android.fiatRate +import fr.acinq.phoenix.android.navController +import fr.acinq.phoenix.android.navigateToPaymentDetails import fr.acinq.phoenix.android.utils.Converter.toAbsoluteDateTimeString import fr.acinq.phoenix.android.utils.Converter.toFiat import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.MSatDisplayPolicy import fr.acinq.phoenix.android.utils.copyToClipboard +import fr.acinq.phoenix.android.utils.mutedBgColor import fr.acinq.phoenix.data.ExchangeRate import fr.acinq.phoenix.data.WalletPaymentInfo +import fr.acinq.phoenix.utils.extensions.amountFeeCredit +import fr.acinq.phoenix.utils.extensions.relatedPaymentIds @Composable @@ -92,6 +102,7 @@ fun PaymentDetailsTechnicalView( is IncomingPayment.ReceivedWith.LightningPayment -> ReceivedWithLightning(it, rateThen) is IncomingPayment.ReceivedWith.NewChannel -> ReceivedWithNewChannel(it, rateThen) is IncomingPayment.ReceivedWith.SpliceIn -> ReceivedWithSpliceIn(it, rateThen) + is IncomingPayment.ReceivedWith.AddedToFeeCredit -> ReceivedWithFeeCredit(it, rateThen) } } } @@ -180,7 +191,7 @@ private fun HeaderForIncoming( // -- payment type TechnicalRow(label = stringResource(id = R.string.paymentdetails_payment_type_label)) { Text( - when (payment.origin) { + text = when (payment.origin) { is IncomingPayment.Origin.Invoice -> stringResource(R.string.paymentdetails_normal_incoming) is IncomingPayment.Origin.SwapIn -> stringResource(R.string.paymentdetails_swapin) is IncomingPayment.Origin.OnChain -> stringResource(R.string.paymentdetails_swapin) @@ -233,7 +244,7 @@ private fun AmountSection( is InboundLiquidityOutgoingPayment -> { TechnicalRowAmount( label = stringResource(id = R.string.paymentdetails_liquidity_amount_label), - amount = payment.lease.amount.toMilliSatoshi(), + amount = payment.purchase.amount.toMilliSatoshi(), rateThen = rateThen, mSatDisplayPolicy = MSatDisplayPolicy.SHOW ) @@ -245,14 +256,10 @@ private fun AmountSection( ) TechnicalRowAmount( label = stringResource(id = R.string.paymentdetails_liquidity_service_fee_label), - amount = payment.lease.fees.serviceFee.toMilliSatoshi(), + amount = payment.purchase.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( @@ -275,6 +282,14 @@ private fun AmountSection( rateThen = rateThen, mSatDisplayPolicy = MSatDisplayPolicy.SHOW ) + payment.amountFeeCredit?.let { + TechnicalRowAmount( + label = stringResource(R.string.paymentdetails_amount_fee_credit_label), + amount = it, + rateThen = rateThen, + mSatDisplayPolicy = MSatDisplayPolicy.SHOW + ) + } val receivedWithNewChannel = payment.received?.receivedWith?.filterIsInstance() ?: emptyList() val receivedWithSpliceIn = payment.received?.receivedWith?.filterIsInstance() ?: emptyList() if ((receivedWithNewChannel + receivedWithSpliceIn).isNotEmpty()) { @@ -337,18 +352,12 @@ private fun DetailsForLightningOutgoingPayment( private fun DetailsForChannelClose( payment: ChannelCloseOutgoingPayment ) { - TechnicalRowSelectable( - label = stringResource(id = R.string.paymentdetails_channel_id_label), - value = payment.channelId.toHex() - ) + ChannelIdRow(payment.channelId) TechnicalRowSelectable( label = stringResource(id = R.string.paymentdetails_bitcoin_address_label), value = payment.address ) - TechnicalRow( - label = stringResource(id = R.string.paymentdetails_tx_id_label), - content = { TransactionLinkButton(txId = payment.txId) } - ) + TransactionRow(payment.txId) TechnicalRowSelectable( label = stringResource(id = R.string.paymentdetails_closing_type_label), value = when (payment.closingType) { @@ -365,43 +374,50 @@ private fun DetailsForChannelClose( private fun DetailsForCpfp( payment: SpliceCpfpOutgoingPayment ) { - TechnicalRow( - label = stringResource(id = R.string.paymentdetails_tx_id_label), - content = { TransactionLinkButton(txId = payment.txId) } - ) + TransactionRow(payment.txId) } @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(), - ) + TechnicalRow(label = stringResource(id = R.string.paymentdetails_liquidity_purchase_type)) { + Text(text = "${ + when (payment.purchase) { + is LiquidityAds.Purchase.Standard -> "Standard" + is LiquidityAds.Purchase.WithFeeCredit -> "Fee credit" + } + } [${payment.purchase.paymentDetails.paymentType}]") + } + TransactionRow(payment.txId) + ChannelIdRow(channelId = payment.channelId) + val paymentIds = payment.relatedPaymentIds() + val navController = navController + paymentIds.forEach { + TechnicalRowClickable( + label = stringResource(id = R.string.paymentdetails_liquidity_caused_by_label), + onClick = { navigateToPaymentDetails(navController, it, isFromEvent = false) }, + ) { + TextWithIcon( + text = "(incoming) ${it.dbId}", + icon = R.drawable.ic_arrow_down_circle, + maxLines = 1, textOverflow = TextOverflow.Ellipsis, + space = 4.dp + ) + } + } } @Composable private fun DetailsForSpliceOut( payment: SpliceOutgoingPayment ) { - TechnicalRowSelectable( - label = stringResource(id = R.string.paymentdetails_splice_out_channel_label), - value = payment.channelId.toHex() - ) + ChannelIdRow(channelId = payment.channelId, label = stringResource(id = R.string.paymentdetails_splice_out_channel_label)) TechnicalRowSelectable( label = stringResource(id = R.string.paymentdetails_bitcoin_address_label), value = payment.address ) - TechnicalRow( - label = stringResource(id = R.string.paymentdetails_tx_id_label), - content = { TransactionLinkButton(txId = payment.txId) } - ) - + TransactionRow(payment.txId) } @Composable @@ -425,7 +441,7 @@ private fun DetailsForIncoming( Row { Text(text = stringResource(id = R.string.paymentdetails_dualswapin_tx_value, index + 1)) Spacer(modifier = Modifier.width(4.dp)) - TransactionLinkButton(txId = outpoint.txid) + InlineTransactionLink(txId = outpoint.txid) } } } @@ -445,11 +461,12 @@ private fun ReceivedWithLightning( Text(text = stringResource(id = R.string.paymentdetails_received_with_lightning)) } if (receivedWith.channelId != ByteVector32.Zeroes) { - TechnicalRow(label = stringResource(id = R.string.paymentdetails_channel_id_label)) { - Text(text = receivedWith.channelId.toHex()) - } + ChannelIdRow(receivedWith.channelId) + } + receivedWith.fundingFee?.let { + TransactionRow(it.fundingTxId) } - TechnicalRowAmount(label = stringResource(id = R.string.paymentdetails_amount_received_label), amount = receivedWith.amount, rateThen = rateThen) + TechnicalRowAmount(label = stringResource(id = R.string.paymentdetails_amount_received_label), amount = receivedWith.amountReceived, rateThen = rateThen) } @Composable @@ -462,15 +479,10 @@ private fun ReceivedWithNewChannel( } val channelId = receivedWith.channelId if (channelId != ByteVector32.Zeroes) { // backward compat - TechnicalRow(label = stringResource(id = R.string.paymentdetails_channel_id_label)) { - Text(text = channelId.toHex()) - } + ChannelIdRow(channelId) } - TechnicalRow( - label = stringResource(id = R.string.paymentdetails_tx_id_label), - content = { TransactionLinkButton(txId = receivedWith.txId) } - ) - TechnicalRowAmount(label = stringResource(id = R.string.paymentdetails_amount_received_label), amount = receivedWith.amount, rateThen = rateThen) + TransactionRow(receivedWith.txId) + TechnicalRowAmount(label = stringResource(id = R.string.paymentdetails_amount_received_label), amount = receivedWith.amountReceived, rateThen = rateThen) } @Composable @@ -483,15 +495,21 @@ private fun ReceivedWithSpliceIn( } val channelId = receivedWith.channelId if (channelId != ByteVector32.Zeroes) { // backward compat - TechnicalRow(label = stringResource(id = R.string.paymentdetails_channel_id_label)) { - Text(text = channelId.toHex()) - } + ChannelIdRow(channelId) } - TechnicalRow( - label = stringResource(id = R.string.paymentdetails_tx_id_label), - content = { TransactionLinkButton(txId = receivedWith.txId) } - ) - TechnicalRowAmount(label = stringResource(id = R.string.paymentdetails_amount_received_label), amount = receivedWith.amount, rateThen = rateThen) + TransactionRow(receivedWith.txId) + TechnicalRowAmount(label = stringResource(id = R.string.paymentdetails_amount_received_label), amount = receivedWith.amountReceived, rateThen = rateThen) +} + +@Composable +private fun ReceivedWithFeeCredit( + receivedWith: IncomingPayment.ReceivedWith.AddedToFeeCredit, + rateThen: ExchangeRate.BitcoinPriceRate? +) { + TechnicalRow(label = stringResource(id = R.string.paymentdetails_received_with_label)) { + Text(text = stringResource(id = R.string.paymentdetails_received_with_fee_credit)) + } + TechnicalRowAmount(label = stringResource(id = R.string.paymentdetails_amount_added_to_fee_credit_label), amount = receivedWith.amountReceived, rateThen = rateThen) } @Composable @@ -684,3 +702,53 @@ private fun TechnicalRowWithCopy(label: String, value: String) { } } } + +@Composable +private fun TechnicalRowClickable( + label: String, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, + content: @Composable () -> Unit, +) { + TechnicalRow(label = label) { + Clickable( + onClick = onClick, + onLongClick = onLongClick, + modifier = Modifier + .fillMaxWidth() + .offset(x = (-8).dp), + shape = RoundedCornerShape(12.dp), + backgroundColor = mutedBgColor, + ) { + Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) { + content() + } + } + } +} + +@Composable +private fun TransactionRow(txId: TxId) { + val context = LocalContext.current + val link = txUrl(txId = txId) + TechnicalRowClickable( + label = stringResource(id = R.string.paymentdetails_tx_id_label), + onClick = { openLink(context, link) }, + onLongClick = { copyToClipboard(context, txId.toString()) } + ) { + TextWithIcon(text = txId.toString(), icon = R.drawable.ic_external_link, maxLines = 1, textOverflow = TextOverflow.Ellipsis, space = 4.dp) + } +} + +@Composable +private fun ChannelIdRow(channelId: ByteVector32, label: String = stringResource(id = R.string.paymentdetails_channel_id_label)) { + val context = LocalContext.current + val navController = navController + TechnicalRowClickable( + label = label, + onClick = { navController.navigate("${Screen.ChannelDetails.route}?id=${channelId.toHex()}") }, + onLongClick = { copyToClipboard(context, channelId.toHex()) } + ) { + TextWithIcon(text = channelId.toHex(), icon = R.drawable.ic_zap, maxLines = 1, textOverflow = TextOverflow.Ellipsis, space = 4.dp) + } +} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsView.kt index d46179c85..ab4bdb395 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsView.kt @@ -36,6 +36,7 @@ import fr.acinq.phoenix.android.components.Card import fr.acinq.phoenix.android.components.DefaultScreenHeader import fr.acinq.phoenix.android.components.DefaultScreenLayout import fr.acinq.phoenix.android.components.feedback.ErrorMessage +import fr.acinq.phoenix.android.payments.details.splash.PaymentDetailsSplashView import fr.acinq.phoenix.data.WalletPaymentFetchOptions import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.data.WalletPaymentInfo diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentLine.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentLine.kt index 575dc9b0b..6c46c89f3 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentLine.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentLine.kt @@ -34,19 +34,18 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.db.OutgoingPayment import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment @@ -68,6 +67,7 @@ import fr.acinq.phoenix.data.WalletPaymentInfo import fr.acinq.phoenix.data.walletPaymentId import fr.acinq.phoenix.utils.extensions.WalletPaymentState import fr.acinq.phoenix.utils.extensions.incomingOfferMetadata +import fr.acinq.phoenix.utils.extensions.isPaidInTheFuture import fr.acinq.phoenix.utils.extensions.outgoingInvoiceRequest import fr.acinq.phoenix.utils.extensions.state @@ -142,7 +142,8 @@ fun PaymentLine( Row { PaymentDescription(paymentInfo = paymentInfo, contactInfo = contactInfo, modifier = Modifier.weight(1.0f)) Spacer(modifier = Modifier.width(16.dp)) - if (payment.state() != WalletPaymentState.Failure) { + val hideAmount = payment.state() == WalletPaymentState.Failure || (payment is InboundLiquidityOutgoingPayment && payment.isPaidInTheFuture()) + if (!hideAmount) { val isOutgoing = payment is OutgoingPayment if (isAmountRedacted) { Text(text = "****") @@ -176,12 +177,11 @@ private fun PaymentDescription( contactInfo: ContactInfo?, modifier: Modifier = Modifier ) { - val context = LocalContext.current val payment = paymentInfo.payment val metadata = paymentInfo.metadata val peer by business.peerManager.peerState.collectAsState() - val desc = when (paymentInfo.isLegacyMigration(peer)) { + val desc = when (payment.isLegacyMigration(metadata, peer)) { null -> stringResource(id = R.string.paymentdetails_desc_closing_channel) // not sure yet, but we still know it's a closing true -> stringResource(id = R.string.paymentdetails_desc_legacy_migration) false -> metadata.userDescription @@ -190,7 +190,7 @@ private fun PaymentDescription( if (contactInfo != null) offerMetadata.payerNote else null } ?: payment.outgoingInvoiceRequest()?.payerNote - ?: payment.smartDescription(context) + ?: payment.smartDescription() } Text( diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/PaymentSplashStatus.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/PaymentSplashStatus.kt new file mode 100644 index 000000000..6646a9d2d --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/PaymentSplashStatus.kt @@ -0,0 +1,417 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.payments.details.splash + +import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi +import androidx.compose.animation.graphics.res.animatedVectorResource +import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter +import androidx.compose.animation.graphics.vector.AnimatedImageVector +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.TxId +import fr.acinq.lightning.blockchain.electrum.ElectrumConnectionStatus +import fr.acinq.lightning.db.ChannelCloseOutgoingPayment +import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment +import fr.acinq.lightning.db.IncomingPayment +import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment +import fr.acinq.lightning.db.SpliceOutgoingPayment +import fr.acinq.lightning.db.WalletPayment +import fr.acinq.lightning.wire.LiquidityAds +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.business +import fr.acinq.phoenix.android.components.Card +import fr.acinq.phoenix.android.components.Dialog +import fr.acinq.phoenix.android.components.FilledButton +import fr.acinq.phoenix.android.components.ProgressView +import fr.acinq.phoenix.android.components.TextWithIcon +import fr.acinq.phoenix.android.components.openLink +import fr.acinq.phoenix.android.components.txUrl +import fr.acinq.phoenix.android.payments.cpfp.CpfpView +import fr.acinq.phoenix.android.utils.Converter.toRelativeDateString +import fr.acinq.phoenix.android.utils.annotatedStringResource +import fr.acinq.phoenix.android.utils.mutedTextColor +import fr.acinq.phoenix.android.utils.negativeColor +import fr.acinq.phoenix.android.utils.positiveColor +import fr.acinq.phoenix.utils.extensions.minDepthForFunding +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.math.absoluteValue + +@Composable +fun PaymentStatus( + payment: WalletPayment, + fromEvent: Boolean, + onCpfpSuccess: () -> Unit, +) { + val peerManager = business.peerManager + when (payment) { + is LightningOutgoingPayment -> when (payment.status) { + is LightningOutgoingPayment.Status.Pending -> PaymentStatusIcon( + message = { Text(text = stringResource(id = R.string.paymentdetails_status_sent_pending)) }, + imageResId = R.drawable.ic_payment_details_pending_static, + isAnimated = false, + color = mutedTextColor + ) + is LightningOutgoingPayment.Status.Completed.Failed -> PaymentStatusIcon( + message = { Text(text = annotatedStringResource(id = R.string.paymentdetails_status_sent_failed), textAlign = TextAlign.Center) }, + imageResId = R.drawable.ic_payment_details_failure_static, + isAnimated = false, + color = negativeColor + ) + is LightningOutgoingPayment.Status.Completed.Succeeded -> PaymentStatusIcon( + message = { + Text(text = annotatedStringResource(id = R.string.paymentdetails_status_sent_successful, payment.completedAt?.toRelativeDateString() ?: "")) + }, + imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, + isAnimated = fromEvent, + color = positiveColor, + ) + } + is ChannelCloseOutgoingPayment -> when (payment.confirmedAt) { + null -> { + PaymentStatusIcon( + message = null, + imageResId = R.drawable.ic_payment_details_pending_onchain_static, + isAnimated = false, + color = mutedTextColor, + ) + ConfirmationView(payment.txId, payment.channelId, isConfirmed = false, canBeBumped = false, onCpfpSuccess = onCpfpSuccess) + } + else -> { + PaymentStatusIcon( + message = { + Text(text = annotatedStringResource(id = R.string.paymentdetails_status_channelclose_confirmed, payment.completedAt?.toRelativeDateString() ?: "")) + }, + imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, + isAnimated = fromEvent, + color = positiveColor, + ) + ConfirmationView(payment.txId, payment.channelId, isConfirmed = true, canBeBumped = false, onCpfpSuccess) + } + } + is SpliceOutgoingPayment -> when (payment.confirmedAt) { + null -> { + PaymentStatusIcon( + message = null, + imageResId = R.drawable.ic_payment_details_pending_onchain_static, + isAnimated = false, + color = mutedTextColor, + ) + ConfirmationView(payment.txId, payment.channelId, isConfirmed = false, canBeBumped = true, onCpfpSuccess = onCpfpSuccess) + } + else -> { + PaymentStatusIcon( + message = { + Text(text = annotatedStringResource(id = R.string.paymentdetails_status_sent_successful, payment.completedAt!!.toRelativeDateString())) + }, + imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, + isAnimated = fromEvent, + color = positiveColor, + ) + ConfirmationView(payment.txId, payment.channelId, isConfirmed = true, canBeBumped = true, onCpfpSuccess = onCpfpSuccess) + } + } + is SpliceCpfpOutgoingPayment -> when (payment.confirmedAt) { + null -> { + PaymentStatusIcon( + message = null, + imageResId = R.drawable.ic_payment_details_pending_onchain_static, + isAnimated = false, + color = mutedTextColor, + ) + ConfirmationView(payment.txId, payment.channelId, isConfirmed = false, canBeBumped = true, onCpfpSuccess = onCpfpSuccess) + } + else -> { + PaymentStatusIcon( + message = { + Text(text = annotatedStringResource(id = R.string.paymentdetails_status_sent_successful, payment.completedAt!!.toRelativeDateString())) + }, + imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, + isAnimated = fromEvent, + color = positiveColor, + ) + ConfirmationView(payment.txId, payment.channelId, isConfirmed = true, canBeBumped = true, onCpfpSuccess = onCpfpSuccess) + } + } + is IncomingPayment -> { + val received = payment.received + when { + received == null -> { + PaymentStatusIcon( + message = { Text(text = stringResource(id = R.string.paymentdetails_status_received_pending)) }, + imageResId = R.drawable.ic_payment_details_pending_static, + isAnimated = false, + color = mutedTextColor + ) + } + received.receivedWith.isEmpty() -> { + PaymentStatusIcon( + message = { Text(text = stringResource(id = R.string.paymentdetails_status_received_paytoopen_pending)) }, + isAnimated = false, + imageResId = R.drawable.ic_clock, + color = mutedTextColor, + ) + } + received.receivedWith.any { it is IncomingPayment.ReceivedWith.OnChainIncomingPayment && it.lockedAt == null } -> { + PaymentStatusIcon( + message = { + Text(text = stringResource(id = R.string.paymentdetails_status_unconfirmed)) + }, + isAnimated = false, + imageResId = R.drawable.ic_clock, + color = mutedTextColor, + ) + } + payment.completedAt == null -> { + PaymentStatusIcon( + message = { + Text(text = stringResource(id = R.string.paymentdetails_status_received_pending)) + }, + imageResId = R.drawable.ic_payment_details_pending_static, + isAnimated = false, + color = mutedTextColor + ) + } + else -> { + PaymentStatusIcon( + message = { + Text(text = annotatedStringResource(id = R.string.paymentdetails_status_received_successful, payment.completedAt!!.toRelativeDateString())) + }, + imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, + isAnimated = fromEvent, + color = positiveColor, + ) + } + } + received?.receivedWith?.filterIsInstance()?.firstOrNull()?.let { + val nodeParams = business.nodeParamsManager.nodeParams.value + val channelMinDepth by produceState(initialValue = null, key1 = Unit) { + nodeParams?.let { params -> + val channelId = payment.received?.receivedWith?.filterIsInstance()?.firstOrNull()?.channelId + value = channelId?.let { peerManager.getChannelWithCommitments(it)?.minDepthForFunding(params) } + } + } + ConfirmationView(it.txId, it.channelId, isConfirmed = it.confirmedAt != null, canBeBumped = false, onCpfpSuccess = onCpfpSuccess, channelMinDepth) + } + } + is InboundLiquidityOutgoingPayment -> SplashLiquidityStatus(payment = payment, fromEvent = fromEvent) + } +} + +@OptIn(ExperimentalAnimationGraphicsApi::class) +@Composable +fun PaymentStatusIcon( + message: (@Composable ColumnScope.() -> Unit)?, + isAnimated: Boolean, + imageResId: Int, + color: Color, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + val scope = rememberCoroutineScope() + var atEnd by remember { mutableStateOf(false) } + Image( + painter = if (isAnimated) { + rememberAnimatedVectorPainter(AnimatedImageVector.animatedVectorResource(imageResId), atEnd) + } else { + painterResource(id = imageResId) + }, + contentDescription = null, + colorFilter = ColorFilter.tint(color), + modifier = Modifier.size(80.dp) + ) + if (isAnimated) { + LaunchedEffect(key1 = Unit) { + scope.launch { + delay(150) + atEnd = true + } + } + } + message?.let { + Spacer(Modifier.height(16.dp)) + Column { it() } + } + } + +} + +@Composable +private fun ConfirmationView( + txId: TxId, + channelId: ByteVector32, + isConfirmed: Boolean, + canBeBumped: Boolean, + onCpfpSuccess: () -> Unit, + minDepth: Int? = null, // sometimes we know how many confirmations are needed +) { + val txUrl = txUrl(txId = txId) + val context = LocalContext.current + val electrumClient = business.electrumClient + var showBumpTxDialog by remember { mutableStateOf(false) } + + if (isConfirmed) { + FilledButton( + text = stringResource(id = R.string.paymentdetails_status_confirmed), + icon = R.drawable.ic_chain, + backgroundColor = Color.Transparent, + padding = PaddingValues(8.dp), + textStyle = MaterialTheme.typography.button.copy(fontSize = 14.sp), + iconTint = MaterialTheme.colors.primary, + space = 6.dp, + onClick = { openLink(context, txUrl) } + ) + } else { + + suspend fun getConfirmations(): Int { + val confirmations = electrumClient.getConfirmations(txId) + return confirmations ?: run { + delay(5_000) + getConfirmations() + } + } + + val confirmations by produceState(initialValue = null) { + electrumClient.connectionStatus.filterIsInstance().first() + val confirmations = getConfirmations() + value = confirmations + } + confirmations?.absoluteValue?.let { conf -> + if (conf == 0) { + Card( + internalPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + onClick = if (canBeBumped) { + { showBumpTxDialog = true } + } else null, + backgroundColor = Color.Transparent, + horizontalAlignment = Alignment.CenterHorizontally + ) { + TextWithIcon( + text = stringResource(R.string.paymentdetails_status_unconfirmed_zero), + icon = if (canBeBumped) R.drawable.ic_rocket else R.drawable.ic_clock, + textStyle = MaterialTheme.typography.button.copy(fontSize = 14.sp, color = MaterialTheme.colors.primary), + iconTint = MaterialTheme.colors.primary + ) + + if (canBeBumped) { + Text( + text = stringResource(id = R.string.paymentdetails_status_unconfirmed_zero_bump), + style = MaterialTheme.typography.button.copy(fontSize = 14.sp, color = MaterialTheme.colors.primary, fontWeight = FontWeight.Bold), + ) + } + } + } else { + FilledButton( + text = when (minDepth) { + null -> stringResource(R.string.paymentdetails_status_unconfirmed_default, conf) + else -> stringResource(R.string.paymentdetails_status_unconfirmed_with_depth, conf, minDepth) + }, + icon = R.drawable.ic_chain, + onClick = { openLink(context, txUrl) }, + backgroundColor = Color.Transparent, + padding = PaddingValues(8.dp), + textStyle = MaterialTheme.typography.button.copy(fontSize = 14.sp), + iconTint = MaterialTheme.colors.primary, + space = 6.dp, + ) + } + + if (conf == 0 && showBumpTxDialog) { + BumpTransactionDialog(channelId = channelId, onSuccess = onCpfpSuccess, onDismiss = { showBumpTxDialog = false }) + } + } ?: ProgressView( + text = stringResource(id = R.string.paymentdetails_status_unconfirmed_fetching), + textStyle = MaterialTheme.typography.body1.copy(fontSize = 14.sp), + padding = PaddingValues(8.dp), + progressCircleSize = 16.dp, + ) + } +} + +@Composable +private fun BumpTransactionDialog( + channelId: ByteVector32, + onSuccess: () -> Unit, + onDismiss: () -> Unit, +) { + Dialog( + onDismiss = onDismiss, + title = stringResource(id = R.string.cpfp_title), + buttons = null, + ) { + CpfpView(channelId = channelId, onSuccess = onSuccess) + } +} + +@Composable +fun SplashLiquidityStatus(payment: InboundLiquidityOutgoingPayment, fromEvent: Boolean) { + when (val lockedAt = payment.lockedAt) { + null -> { + PaymentStatusIcon( + message = null, + imageResId = R.drawable.ic_payment_details_pending_onchain_static, + isAnimated = false, + color = mutedTextColor, + ) + } + else -> { + PaymentStatusIcon( + message = { + if (payment.purchase.paymentDetails is LiquidityAds.PaymentDetails.FromChannelBalance) { + Text(text = annotatedStringResource(id = R.string.paymentdetails_status_inbound_liquidity_success, lockedAt.toRelativeDateString())) + } else { + Text(text = annotatedStringResource(id = R.string.paymentdetails_status_inbound_liquidity_auto_success, lockedAt.toRelativeDateString())) + } + }, + imageResId = if (fromEvent) R.drawable.ic_payment_details_success_animated else R.drawable.ic_payment_details_success_static, + isAnimated = fromEvent, + color = positiveColor, + ) + } + } +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/PaymentSplashView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/PaymentSplashView.kt new file mode 100644 index 000000000..687df1bde --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/PaymentSplashView.kt @@ -0,0 +1,234 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.payments.details.splash + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import fr.acinq.lightning.db.ChannelCloseOutgoingPayment +import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment +import fr.acinq.lightning.db.IncomingPayment +import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.lightning.db.OutgoingPayment +import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment +import fr.acinq.lightning.db.SpliceOutgoingPayment +import fr.acinq.lightning.wire.LiquidityAds +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.AmountView +import fr.acinq.phoenix.android.components.BorderButton +import fr.acinq.phoenix.android.components.Button +import fr.acinq.phoenix.android.components.DefaultScreenHeader +import fr.acinq.phoenix.android.components.PrimarySeparator +import fr.acinq.phoenix.android.components.SplashClickableContent +import fr.acinq.phoenix.android.components.SplashLabelRow +import fr.acinq.phoenix.android.components.SplashLayout +import fr.acinq.phoenix.android.components.TextInput +import fr.acinq.phoenix.android.components.TextWithIcon +import fr.acinq.phoenix.android.utils.borderColor +import fr.acinq.phoenix.android.utils.mutedBgColor +import fr.acinq.phoenix.android.utils.negativeColor +import fr.acinq.phoenix.android.utils.positiveColor +import fr.acinq.phoenix.data.WalletPaymentId +import fr.acinq.phoenix.data.WalletPaymentInfo +import fr.acinq.phoenix.utils.extensions.WalletPaymentState +import fr.acinq.phoenix.utils.extensions.state + +@Composable +fun PaymentDetailsSplashView( + onBackClick: () -> Unit, + data: WalletPaymentInfo, + onDetailsClick: (WalletPaymentId) -> Unit, + onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit, + fromEvent: Boolean, +) { + val payment = data.payment + SplashLayout( + header = { DefaultScreenHeader(onBackClick = onBackClick) }, + topContent = { PaymentStatus(data.payment, fromEvent, onCpfpSuccess = onBackClick) } + ) { + if (payment is InboundLiquidityOutgoingPayment && payment.purchase.paymentDetails is LiquidityAds.PaymentDetails.FromFutureHtlc) { + Unit + } else { + AmountView( + amount = when (payment) { + is InboundLiquidityOutgoingPayment -> payment.amount + is OutgoingPayment -> payment.amount - payment.fees + is IncomingPayment -> payment.amount + }, + amountTextStyle = MaterialTheme.typography.body1.copy(fontSize = 30.sp), + separatorSpace = 4.dp, + prefix = stringResource(id = if (payment is OutgoingPayment) R.string.paymentline_prefix_sent else R.string.paymentline_prefix_received) + ) + Spacer(modifier = Modifier.height(36.dp)) + PrimarySeparator( + height = 6.dp, + color = when (payment.state()) { + WalletPaymentState.Failure -> negativeColor + WalletPaymentState.SuccessOffChain, WalletPaymentState.SuccessOnChain -> positiveColor + else -> mutedBgColor + } + ) + } + Spacer(modifier = Modifier.height(36.dp)) + + when (payment) { + is IncomingPayment -> SplashIncoming(payment = payment, metadata = data.metadata, onMetadataDescriptionUpdate = onMetadataDescriptionUpdate) + is LightningOutgoingPayment -> SplashLightningOutgoing(payment = payment, metadata = data.metadata, onMetadataDescriptionUpdate = onMetadataDescriptionUpdate) + is ChannelCloseOutgoingPayment -> SplashChannelClose(payment = payment, metadata = data.metadata, onMetadataDescriptionUpdate = onMetadataDescriptionUpdate) + is SpliceCpfpOutgoingPayment -> SplashSpliceOutCpfp(payment = payment, metadata = data.metadata, onMetadataDescriptionUpdate = onMetadataDescriptionUpdate) + is SpliceOutgoingPayment -> SplashSpliceOut(payment = payment, metadata = data.metadata, onMetadataDescriptionUpdate = onMetadataDescriptionUpdate) + is InboundLiquidityOutgoingPayment -> SplashLiquidityPurchase(payment = payment) + } + + Spacer(modifier = Modifier.height(48.dp)) + BorderButton( + text = stringResource(id = R.string.paymentdetails_details_button), + borderColor = borderColor, + textStyle = MaterialTheme.typography.caption, + icon = R.drawable.ic_tool, + iconTint = MaterialTheme.typography.caption.color, + onClick = { onDetailsClick(data.id()) }, + ) + } +} + +@Composable +fun SplashDescription( + description: String?, + userDescription: String?, + paymentId: WalletPaymentId, + onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit, +) { + var showEditDescriptionDialog by remember { mutableStateOf(false) } + + Spacer(modifier = Modifier.height(8.dp)) + if (!(description.isNullOrBlank() && !userDescription.isNullOrBlank())) { + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_desc_label)) { + if (description.isNullOrBlank()) { + Text( + text = stringResource(id = R.string.paymentdetails_no_description), + style = MaterialTheme.typography.caption.copy(fontStyle = FontStyle.Italic) + ) + } else { + Text(text = description) + } + } + } + SplashLabelRow(label = if (userDescription.isNullOrBlank()) "" else stringResource(id = R.string.paymentdetails_note_label)) { + SplashClickableContent(onClick = { showEditDescriptionDialog = true }) { + if (!userDescription.isNullOrBlank()) { + Text(text = userDescription) + Spacer(modifier = Modifier.height(8.dp)) + } + TextWithIcon( + text = stringResource( + id = when (userDescription) { + null -> R.string.paymentdetails_attach_desc_button + else -> R.string.paymentdetails_edit_desc_button + } + ), + textStyle = MaterialTheme.typography.subtitle2, + icon = R.drawable.ic_edit, + iconTint = MaterialTheme.typography.subtitle2.color, + space = 6.dp, + ) + } + } + + if (showEditDescriptionDialog) { + CustomNoteDialog( + initialDescription = userDescription, + onConfirm = { + onMetadataDescriptionUpdate(paymentId, it?.trim()?.takeIf { it.isNotBlank() }) + showEditDescriptionDialog = false + }, + onDismiss = { showEditDescriptionDialog = false } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CustomNoteDialog( + initialDescription: String?, + onConfirm: (String?) -> Unit, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) + var description by rememberSaveable { mutableStateOf(initialDescription) } + + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = onDismiss, + containerColor = MaterialTheme.colors.surface, + contentColor = MaterialTheme.colors.onSurface, + scrimColor = MaterialTheme.colors.onBackground.copy(alpha = 0.1f), + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(top = 0.dp, start = 24.dp, end = 24.dp, bottom = 70.dp), + ) { + Text(text = stringResource(id = R.string.paymentdetails_edit_dialog_title), style = MaterialTheme.typography.body2) + Spacer(modifier = Modifier.height(16.dp)) + TextInput( + modifier = Modifier.fillMaxWidth(), + text = description ?: "", + onTextChange = { description = it.takeIf { it.isNotBlank() } }, + minLines = 2, + maxLines = 6, + maxChars = 280, + staticLabel = stringResource(id = R.string.paymentdetails_edit_dialog_input_label) + ) + Spacer(modifier = Modifier.height(24.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + Button(onClick = onDismiss, text = stringResource(id = R.string.btn_cancel), shape = CircleShape) + Button( + onClick = { onConfirm(description) }, + text = stringResource(id = R.string.btn_save), + icon = R.drawable.ic_check, + enabled = description != initialDescription, + space = 8.dp, + shape = CircleShape + ) + } + } + } +} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashChannelClose.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashChannelClose.kt new file mode 100644 index 000000000..ceac944ea --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashChannelClose.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.payments.details.splash + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import fr.acinq.lightning.db.ChannelCloseOutgoingPayment +import fr.acinq.lightning.utils.msat +import fr.acinq.phoenix.android.LocalBitcoinUnit +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.business +import fr.acinq.phoenix.android.components.SplashLabelRow +import fr.acinq.phoenix.android.utils.Converter.toPrettyString +import fr.acinq.phoenix.android.utils.MSatDisplayPolicy +import fr.acinq.phoenix.android.utils.isLegacyMigration +import fr.acinq.phoenix.android.utils.smartDescription +import fr.acinq.phoenix.data.WalletPaymentId +import fr.acinq.phoenix.data.WalletPaymentMetadata +import fr.acinq.phoenix.data.walletPaymentId + +@Composable +fun SplashChannelClose( + payment: ChannelCloseOutgoingPayment, + metadata: WalletPaymentMetadata, + onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit, +) { + val peer by business.peerManager.peerState.collectAsState() + + val isLegacyMigration = payment.isLegacyMigration(metadata, peer) + val description = when (isLegacyMigration) { + null -> stringResource(id = R.string.paymentdetails_desc_closing_channel) // not sure yet, but we still know it's a closing + true -> stringResource(id = R.string.paymentdetails_desc_legacy_migration) + false -> payment.smartDescription() + } + + SplashDescription( + description = description, + userDescription = metadata.userDescription, + paymentId = payment.walletPaymentId(), + onMetadataDescriptionUpdate = onMetadataDescriptionUpdate + ) + SplashDestination(payment, metadata) + SplashFee(payment = payment) +} + +@Composable +private fun SplashDestination(payment: ChannelCloseOutgoingPayment, metadata: WalletPaymentMetadata) { + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_destination_label), icon = R.drawable.ic_chain) { + SelectionContainer { + Text(text = payment.address) + } + } +} + +@Composable +private fun SplashFee(payment: ChannelCloseOutgoingPayment) { + val btcUnit = LocalBitcoinUnit.current + if (payment.fees > 0.msat) { + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_fees_label)) { + Text(text = payment.fees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) + } + } +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashIncoming.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashIncoming.kt new file mode 100644 index 000000000..4d43a62ca --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashIncoming.kt @@ -0,0 +1,156 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.payments.details.splash + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import fr.acinq.bitcoin.PublicKey +import fr.acinq.lightning.db.IncomingPayment +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.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.SplashLabelRow +import fr.acinq.phoenix.android.components.contact.ContactCompactView +import fr.acinq.phoenix.android.components.contact.OfferContactState +import fr.acinq.phoenix.android.utils.Converter.toPrettyString +import fr.acinq.phoenix.android.utils.MSatDisplayPolicy +import fr.acinq.phoenix.android.utils.smartDescription +import fr.acinq.phoenix.data.WalletPaymentId +import fr.acinq.phoenix.data.WalletPaymentMetadata +import fr.acinq.phoenix.data.walletPaymentId +import fr.acinq.phoenix.utils.extensions.incomingOfferMetadata + +@Composable +fun SplashIncoming( + payment: IncomingPayment, + metadata: WalletPaymentMetadata, + onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit, +) { + payment.incomingOfferMetadata()?.let { meta -> + meta.payerNote?.takeIf { it.isNotBlank() }?.let { + OfferPayerNote(payerNote = it) + } + OfferSentBy(payerPubkey = meta.payerKey, !meta.payerNote.isNullOrBlank()) + } + + SplashDescription( + description = payment.smartDescription(), + userDescription = metadata.userDescription, + paymentId = payment.walletPaymentId(), + onMetadataDescriptionUpdate = onMetadataDescriptionUpdate, + ) + SplashFee(payment) +} + +@Composable +fun OfferPayerNote(payerNote: String) { + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_offer_note_label)) { + Text(text = payerNote) + } +} + +@Composable +private fun OfferSentBy(payerPubkey: PublicKey?, hasPayerNote: Boolean) { + val contactsManager = business.contactsManager + val contactState = remember { mutableStateOf(OfferContactState.Init) } + LaunchedEffect(Unit) { + contactState.value = payerPubkey?.let { + contactsManager.getContactForPayerPubkey(it) + }?.let { OfferContactState.Found(it) } ?: OfferContactState.NotFound + } + + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_offer_sender_label)) { + when (val res = contactState.value) { + is OfferContactState.Init -> Text(text = stringResource(id = R.string.utils_loading_data)) + is OfferContactState.NotFound -> { + Text(text = stringResource(id = R.string.paymentdetails_offer_sender_unknown)) + if (hasPayerNote) { + Spacer(modifier = Modifier.height(4.dp)) + Text(text = stringResource(id = R.string.paymentdetails_offer_sender_unknown_details), style = MaterialTheme.typography.subtitle2) + } + } + is OfferContactState.Found -> { + ContactCompactView( + contact = res.contact, + currentOffer = null, + onContactChange = { contactState.value = if (it == null) OfferContactState.NotFound else OfferContactState.Found(it) }, + ) + } + } + } +} + +@Composable +private fun SplashFee( + payment: IncomingPayment +) { + val btcUnit = LocalBitcoinUnit.current + val receivedWithOnChain = remember(payment) { payment.received?.receivedWith?.filterIsInstance() ?: emptyList() } + val receivedWithLightning = remember(payment) { payment.received?.receivedWith?.filterIsInstance() ?: emptyList() } + + if (receivedWithOnChain.isNotEmpty() || receivedWithLightning.isNotEmpty()) { + + val paymentsManager = business.paymentsManager + val txIds = remember(receivedWithLightning) { receivedWithLightning.mapNotNull { it.fundingFee?.fundingTxId } } + val relatedLiquidityPayments by produceState(initialValue = emptyList()) { + value = txIds.mapNotNull { paymentsManager.getLiquidityPurchaseForTxId(it) } + } + + val serviceFee = remember(receivedWithOnChain, relatedLiquidityPayments) { + receivedWithOnChain.map { it.serviceFee }.sum() + relatedLiquidityPayments.map { it.feePaidFromFutureHtlc.serviceFee.toMilliSatoshi() }.sum() + } + val miningFee = remember(receivedWithOnChain, relatedLiquidityPayments) { + receivedWithOnChain.map { it.miningFee }.sum() + relatedLiquidityPayments.map { it.feePaidFromFutureHtlc.miningFee }.sum() + } + + Spacer(modifier = Modifier.height(8.dp)) + if (serviceFee > 0.msat) { + SplashLabelRow( + label = stringResource(id = R.string.paymentdetails_service_fees_label), + helpMessage = stringResource(R.string.paymentdetails_service_fees_desc) + ) { + Text(text = serviceFee.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW)) + } + Spacer(modifier = Modifier.height(8.dp)) + } + + if (miningFee > 0.sat) { + SplashLabelRow( + label = stringResource(id = R.string.paymentdetails_funding_fees_label), + helpMessage = stringResource(R.string.paymentdetails_funding_fees_desc) + ) { + Text(text = miningFee.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.HIDE)) + } + } + } +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashLightningOut.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashLightningOut.kt new file mode 100644 index 000000000..30811e798 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashLightningOut.kt @@ -0,0 +1,235 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.payments.details.splash + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import fr.acinq.bitcoin.utils.Either +import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.lightning.payment.FinalFailure +import fr.acinq.lightning.payment.OutgoingPaymentFailure +import fr.acinq.phoenix.android.LocalBitcoinUnit +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.ProgressView +import fr.acinq.phoenix.android.components.SplashLabelRow +import fr.acinq.phoenix.android.components.WebLink +import fr.acinq.phoenix.android.components.contact.ContactOrOfferView +import fr.acinq.phoenix.android.utils.Converter.toPrettyString +import fr.acinq.phoenix.android.utils.MSatDisplayPolicy +import fr.acinq.phoenix.android.utils.smartDescription +import fr.acinq.phoenix.data.LnurlPayMetadata +import fr.acinq.phoenix.data.WalletPaymentId +import fr.acinq.phoenix.data.WalletPaymentMetadata +import fr.acinq.phoenix.data.lnurl.LnurlPay +import fr.acinq.phoenix.data.walletPaymentId +import fr.acinq.phoenix.utils.extensions.WalletPaymentState +import fr.acinq.phoenix.utils.extensions.outgoingInvoiceRequest +import fr.acinq.phoenix.utils.extensions.state +import io.ktor.http.Url +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +@Composable +fun SplashLightningOutgoing( + payment: LightningOutgoingPayment, + metadata: WalletPaymentMetadata, + onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit, +) { + metadata.lnurl?.let { lnurlMeta -> + LnurlPayInfoView(payment, lnurlMeta) + } + + payment.outgoingInvoiceRequest()?.payerNote?.takeIf { it.isNotBlank() }?.let { + OfferPayerNote(payerNote = it) + } + + SplashDescription( + description = payment.smartDescription(), + userDescription = metadata.userDescription, + paymentId = payment.walletPaymentId(), + onMetadataDescriptionUpdate = onMetadataDescriptionUpdate + ) + SplashDestination(payment, metadata) + SplashFee(payment = payment) + + (payment.status as? LightningOutgoingPayment.Status.Completed.Failed)?.let { status -> + PaymentErrorView(status = status, failedParts = payment.parts.map { it.status }.filterIsInstance()) + } +} + +@Composable +private fun SplashDestination(payment: LightningOutgoingPayment, metadata: WalletPaymentMetadata) { + val lnId = metadata.lnurl?.pay?.metadata?.lnid?.takeIf { it.isNotBlank() } + if (lnId != null) { + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_destination_label), icon = R.drawable.ic_zap) { + SelectionContainer { + Text(text = lnId) + } + } + } + val details = payment.details + if (details is LightningOutgoingPayment.Details.Blinded) { + val offer = details.paymentRequest.invoiceRequest.offer + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_destination_label)) { + ContactOrOfferView(offer = offer) + } + } +} + +@Composable +private fun SplashFee(payment: LightningOutgoingPayment) { + val btcUnit = LocalBitcoinUnit.current + if (payment.state() == WalletPaymentState.SuccessOffChain) { + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_fees_label)) { + Text(text = payment.fees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) + } + } +} + +@Composable +private fun LnurlPayInfoView(payment: LightningOutgoingPayment, metadata: LnurlPayMetadata) { + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_lnurlpay_service)) { + SelectionContainer { + Text(text = metadata.pay.callback.host) + } + } + metadata.successAction?.let { + LnurlSuccessAction(payment = payment, action = it) + } +} + +@Composable +private fun LnurlSuccessAction(payment: LightningOutgoingPayment, action: LnurlPay.Invoice.SuccessAction) { + Spacer(modifier = Modifier.height(8.dp)) + when (action) { + is LnurlPay.Invoice.SuccessAction.Message -> { + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_lnurlpay_action_message_label)) { + SelectionContainer { + Text(text = action.message) + } + } + } + is LnurlPay.Invoice.SuccessAction.Url -> { + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_lnurlpay_action_url_label)) { + Text(text = action.description) + WebLink(text = stringResource(id = R.string.paymentdetails_lnurlpay_action_url_button), url = action.url.toString()) + } + } + is LnurlPay.Invoice.SuccessAction.Aes -> { + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_lnurlpay_action_aes_label)) { + val status = payment.status + if (status is LightningOutgoingPayment.Status.Completed.Succeeded.OffChain) { + val deciphered by produceState(initialValue = null) { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(status.preimage.toByteArray(), "AES"), IvParameterSpec(action.iv.toByteArray())) + value = String(cipher.doFinal(action.ciphertext.toByteArray()), Charsets.UTF_8) + } + Text(text = action.description) + when (deciphered) { + null -> ProgressView(text = stringResource(id = R.string.paymentdetails_lnurlpay_action_aes_decrypting), padding = PaddingValues(0.dp)) + else -> { + val url = try { + Url(deciphered!!) + } catch (e: Exception) { + null + } + if (url != null) { + WebLink(text = stringResource(id = R.string.paymentdetails_lnurlpay_action_url_button), url = url.toString()) + } else { + SelectionContainer { + Text(text = deciphered!!) + } + } + } + } + } else { + Text(text = stringResource(id = R.string.paymentdetails_lnurlpay_action_aes_decrypting)) + } + } + } + } +} + +@Composable +private fun PaymentErrorView(status: LightningOutgoingPayment.Status.Completed.Failed, failedParts: List) { + val failure = remember(status, failedParts) { OutgoingPaymentFailure(status.reason, failedParts) } + translatePaymentError(failure).let { + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_error_label)) { + Text(text = it) + } + } +} + +@Composable +fun translatePaymentError(paymentFailure: OutgoingPaymentFailure): String { + val context = LocalContext.current + val errorMessage = remember(key1 = paymentFailure) { + when (val result = paymentFailure.explain()) { + is Either.Left -> { + when (val partFailure = result.value) { + is LightningOutgoingPayment.Part.Status.Failure.Uninterpretable -> partFailure.message + LightningOutgoingPayment.Part.Status.Failure.ChannelIsClosing -> context.getString(R.string.outgoing_failuremessage_channel_closing) + LightningOutgoingPayment.Part.Status.Failure.ChannelIsSplicing -> context.getString(R.string.outgoing_failuremessage_channel_splicing) + LightningOutgoingPayment.Part.Status.Failure.NotEnoughFees -> context.getString(R.string.outgoing_failuremessage_not_enough_fee) + LightningOutgoingPayment.Part.Status.Failure.NotEnoughFunds -> context.getString(R.string.outgoing_failuremessage_not_enough_balance) + LightningOutgoingPayment.Part.Status.Failure.PaymentAmountTooBig -> context.getString(R.string.outgoing_failuremessage_too_big) + LightningOutgoingPayment.Part.Status.Failure.PaymentAmountTooSmall -> context.getString(R.string.outgoing_failuremessage_too_small) + LightningOutgoingPayment.Part.Status.Failure.PaymentExpiryTooBig -> context.getString(R.string.outgoing_failuremessage_expiry_too_big) + LightningOutgoingPayment.Part.Status.Failure.RecipientRejectedPayment -> context.getString(R.string.outgoing_failuremessage_rejected_by_recipient) + LightningOutgoingPayment.Part.Status.Failure.RecipientIsOffline -> context.getString(R.string.outgoing_failuremessage_recipient_offline) + LightningOutgoingPayment.Part.Status.Failure.RecipientLiquidityIssue -> context.getString(R.string.outgoing_failuremessage_not_enough_liquidity) + LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure -> context.getString(R.string.outgoing_failuremessage_temporary_failure) + LightningOutgoingPayment.Part.Status.Failure.TooManyPendingPayments -> context.getString(R.string.outgoing_failuremessage_too_many_pending) + } + } + is Either.Right -> { + when (result.value) { + FinalFailure.InvalidPaymentId -> context.getString(R.string.outgoing_failuremessage_invalid_id) + FinalFailure.AlreadyPaid -> context.getString(R.string.outgoing_failuremessage_alreadypaid) + FinalFailure.ChannelClosing -> context.getString(R.string.outgoing_failuremessage_channel_closing) + FinalFailure.ChannelNotConnected -> context.getString(R.string.outgoing_failuremessage_not_connected) + FinalFailure.ChannelOpening -> context.getString(R.string.outgoing_failuremessage_channel_opening) + FinalFailure.FeaturesNotSupported -> context.getString(R.string.outgoing_failuremessage_unsupported_features) + FinalFailure.InsufficientBalance -> context.getString(R.string.outgoing_failuremessage_not_enough_balance) + FinalFailure.InvalidPaymentAmount -> context.getString(R.string.outgoing_failuremessage_invalid_amount) + FinalFailure.NoAvailableChannels -> context.getString(R.string.outgoing_failuremessage_no_available_channels) + FinalFailure.RecipientUnreachable -> context.getString(R.string.outgoing_failuremessage_noroutefound) + FinalFailure.RetryExhausted -> context.getString(R.string.outgoing_failuremessage_noroutefound) + FinalFailure.UnknownError -> context.getString(R.string.outgoing_failuremessage_unknown) + FinalFailure.WalletRestarted -> context.getString(R.string.outgoing_failuremessage_restarted) + } + } + } + } + return errorMessage +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashLiquidityPurchase.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashLiquidityPurchase.kt new file mode 100644 index 000000000..1566b09bc --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashLiquidityPurchase.kt @@ -0,0 +1,144 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.payments.details.splash + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment +import fr.acinq.lightning.wire.LiquidityAds +import fr.acinq.phoenix.android.LocalBitcoinUnit +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.Screen +import fr.acinq.phoenix.android.business +import fr.acinq.phoenix.android.components.BorderButton +import fr.acinq.phoenix.android.components.BottomSheetDialog +import fr.acinq.phoenix.android.components.Button +import fr.acinq.phoenix.android.components.Clickable +import fr.acinq.phoenix.android.components.SplashClickableContent +import fr.acinq.phoenix.android.components.SplashLabelRow +import fr.acinq.phoenix.android.components.TextWithIcon +import fr.acinq.phoenix.android.navController +import fr.acinq.phoenix.android.navigateToPaymentDetails +import fr.acinq.phoenix.android.payments.details.PaymentLine +import fr.acinq.phoenix.android.payments.details.PaymentLineLoading +import fr.acinq.phoenix.android.utils.Converter.toPrettyString +import fr.acinq.phoenix.android.utils.MSatDisplayPolicy +import fr.acinq.phoenix.android.utils.mutedBgColor +import fr.acinq.phoenix.data.WalletPaymentFetchOptions +import fr.acinq.phoenix.data.WalletPaymentId +import fr.acinq.phoenix.data.WalletPaymentInfo +import fr.acinq.phoenix.utils.extensions.isManualPurchase +import fr.acinq.phoenix.utils.extensions.isPaidInTheFuture +import fr.acinq.phoenix.utils.extensions.relatedPaymentIds +import kotlinx.coroutines.launch + +@Composable +fun SplashLiquidityPurchase( + payment: InboundLiquidityOutgoingPayment, +) { + SplashPurchase(payment = payment) + SplashFee(payment = payment) + SplashRelatedPayments(payment) +} + +@Composable +private fun SplashPurchase( + payment: InboundLiquidityOutgoingPayment, +) { + val btcUnit = LocalBitcoinUnit.current + SplashLabelRow(label = "Liquidity") { + Text(text = payment.purchase.amount.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) + } +} + +@Composable +private fun SplashFee( + payment: InboundLiquidityOutgoingPayment +) { + val btcUnit = LocalBitcoinUnit.current + if (!payment.isPaidInTheFuture()) { + Spacer(modifier = Modifier.height(8.dp)) + val miningFee = payment.feePaidFromChannelBalance.miningFee + val serviceFee = payment.feePaidFromChannelBalance.serviceFee + SplashLabelRow( + label = stringResource(id = R.string.paymentdetails_liquidity_miner_fee_label), + helpMessage = stringResource(id = R.string.paymentdetails_liquidity_miner_fee_help) + ) { + Text(text = miningFee.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 = serviceFee.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) + } + } +} + +@Composable +private fun SplashRelatedPayments(payment: InboundLiquidityOutgoingPayment) { + val relatedPaymentIds = payment.relatedPaymentIds() + if (relatedPaymentIds.isNotEmpty()) { + val navController = navController + val paymentId = relatedPaymentIds.first() + Spacer(modifier = Modifier.height(4.dp)) + SplashLabelRow( + label = stringResource(id = R.string.paymentdetails_liquidity_caused_by_label), + helpMessage = if (payment.isManualPurchase()) null else stringResource(id = R.string.paymentdetails_liquidity_caused_by_help), + helpLink = stringResource(id = R.string.paymentdetails_liquidity_caused_by_help_link) to "https://acinq.co/faq", + ) { + Button( + text = paymentId.dbId, + icon = R.drawable.ic_zap, + onClick = { navigateToPaymentDetails(navController, paymentId, isFromEvent = false) }, + maxLines = 1, + padding = PaddingValues(horizontal = 7.dp, vertical = 5.dp), + space = 4.dp, + shape = RoundedCornerShape(12.dp), + backgroundColor = mutedBgColor, + modifier = Modifier.widthIn(max = 130.dp) + ) + } + } +} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashSpliceOut.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashSpliceOut.kt new file mode 100644 index 000000000..bbe8b431c --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashSpliceOut.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.payments.details.splash + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import fr.acinq.lightning.db.SpliceOutgoingPayment +import fr.acinq.phoenix.android.LocalBitcoinUnit +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.SplashLabelRow +import fr.acinq.phoenix.android.utils.Converter.toPrettyString +import fr.acinq.phoenix.android.utils.MSatDisplayPolicy +import fr.acinq.phoenix.android.utils.smartDescription +import fr.acinq.phoenix.data.WalletPaymentId +import fr.acinq.phoenix.data.WalletPaymentMetadata +import fr.acinq.phoenix.data.walletPaymentId + +@Composable +fun SplashSpliceOut( + payment: SpliceOutgoingPayment, + metadata: WalletPaymentMetadata, + onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit, +) { + SplashDescription( + description = payment.smartDescription(), + userDescription = metadata.userDescription, + paymentId = payment.walletPaymentId(), + onMetadataDescriptionUpdate = onMetadataDescriptionUpdate + ) + SplashDestination(payment) + SplashFee(payment = payment) +} + +@Composable +private fun SplashDestination(payment: SpliceOutgoingPayment) { + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_destination_label), icon = R.drawable.ic_chain) { + SelectionContainer { + Text(text = payment.address) + } + } +} + +@Composable +private fun SplashFee(payment: SpliceOutgoingPayment) { + val btcUnit = LocalBitcoinUnit.current + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_fees_label)) { + Text(text = payment.fees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) + } +} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashSpliceOutCpfp.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashSpliceOutCpfp.kt new file mode 100644 index 000000000..d34fa57d4 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/splash/SplashSpliceOutCpfp.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.android.payments.details.splash + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment +import fr.acinq.phoenix.android.LocalBitcoinUnit +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.SplashLabelRow +import fr.acinq.phoenix.android.utils.Converter.toPrettyString +import fr.acinq.phoenix.android.utils.MSatDisplayPolicy +import fr.acinq.phoenix.android.utils.smartDescription +import fr.acinq.phoenix.data.WalletPaymentId +import fr.acinq.phoenix.data.WalletPaymentMetadata +import fr.acinq.phoenix.data.walletPaymentId + +@Composable +fun SplashSpliceOutCpfp( + payment: SpliceCpfpOutgoingPayment, + metadata: WalletPaymentMetadata, + onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit, +) { + SplashDescription( + description = payment.smartDescription(), + userDescription = metadata.userDescription, + paymentId = payment.walletPaymentId(), + onMetadataDescriptionUpdate = onMetadataDescriptionUpdate + ) + SplashDestination() + SplashFee(payment = payment) +} + +@Composable +private fun SplashDestination() { + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_destination_label), icon = R.drawable.ic_chain) { + SelectionContainer { + Text(text = stringResource(id = R.string.paymentdetails_destination_cpfp_value)) + } + } +} + +@Composable +private fun SplashFee(payment: SpliceCpfpOutgoingPayment) { + val btcUnit = LocalBitcoinUnit.current + Spacer(modifier = Modifier.height(8.dp)) + SplashLabelRow(label = stringResource(id = R.string.paymentdetails_fees_label)) { + Text(text = payment.fees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS)) + } +} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/history/CsvExportViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/history/CsvExportViewModel.kt index df6c5e878..99176b661 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 @@ -29,9 +29,8 @@ import androidx.lifecycle.viewModelScope import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.phoenix.android.BuildConfig import fr.acinq.phoenix.android.utils.Converter.toAbsoluteDateTimeString -import fr.acinq.phoenix.android.utils.smartDescription +import fr.acinq.phoenix.android.utils.basicDescription import fr.acinq.phoenix.data.WalletPaymentFetchOptions -import fr.acinq.phoenix.db.SqlitePaymentsDb import fr.acinq.phoenix.managers.DatabaseManager import fr.acinq.phoenix.managers.PaymentsFetcher import fr.acinq.phoenix.managers.PeerManager @@ -109,7 +108,7 @@ class CsvExportViewModel( ).map { paymentRow -> paymentsFetcher.getPayment(paymentRow, WalletPaymentFetchOptions.All)?.let { info -> val descriptions = listOf( - info.payment.smartDescription(context), + info.payment.basicDescription(), info.metadata.userDescription, info.metadata.lnurl?.pay?.metadata?.longDesc ).mapNotNull { it.takeIf { !it.isNullOrBlank() } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/history/PaymentsHistoryView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/history/PaymentsHistoryView.kt index 66770c983..662f96449 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/history/PaymentsHistoryView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/history/PaymentsHistoryView.kt @@ -147,7 +147,7 @@ fun PaymentsHistoryView( .distinctUntilChanged() .filter { index -> val entriesInListCount = groupedPayments.entries.size + payments.size - val isLastElementFetched = index == entriesInListCount - 1 + val isLastElementFetched = index >= entriesInListCount - 1 isLastElementFetched } .distinctUntilChanged() @@ -155,7 +155,7 @@ fun PaymentsHistoryView( val hasMorePaymentsToFetch = payments.size < allPaymentsCount if (hasMorePaymentsToFetch) { // Subscribe to a bit more payments. Ideally would be the screen height / height of each payment. - paymentsViewModel.subscribeToPayments(offset = 0, count = index + 10) + paymentsViewModel.subscribeToPayments(offset = 0, count = index + 14) } } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityView.kt index b02dc3c1f..245b9881b 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityView.kt @@ -53,6 +53,7 @@ import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.ChannelCommand +import fr.acinq.lightning.channel.ChannelFundingResponse import fr.acinq.lightning.channel.ChannelManagementFees import fr.acinq.lightning.utils.sat import fr.acinq.lightning.utils.sum @@ -237,7 +238,7 @@ private fun RequestLiquidityBottomSection( ErrorMessage(header = stringResource(id = R.string.validation_invalid_amount)) } else { ReviewLiquidityRequest( - onConfirm = { vm.requestInboundLiquidity(amount = state.amount, feerate = state.actualFeerate) } + onConfirm = { vm.requestInboundLiquidity(amount = state.amount, feerate = state.actualFeerate, fundingRate = state.fundingRate) } ) } } @@ -245,7 +246,7 @@ private fun RequestLiquidityBottomSection( ProgressView(text = stringResource(id = R.string.liquidityads_requesting_spinner)) } is RequestLiquidityState.Complete.Success -> { - LeaseSuccessDetails(liquidityDetails = state.response) + LiquiditySuccessDetails(liquidityDetails = state.response) } is RequestLiquidityState.Error.NoChannelsAvailable -> { ErrorMessage( @@ -253,6 +254,12 @@ private fun RequestLiquidityBottomSection( details = stringResource(id = R.string.liquidityads_error_channels_unavailable) ) } + is RequestLiquidityState.Error.InvalidFundingAmount -> { + ErrorMessage( + header = stringResource(id = R.string.liquidityads_error_header), + details = stringResource(id = R.string.liquidityads_error_invalid_funding_amount) + ) + } is RequestLiquidityState.Error.Thrown -> { ErrorMessage( header = stringResource(id = R.string.liquidityads_error_header), @@ -375,10 +382,10 @@ private fun ReviewLiquidityRequest( } @Composable -private fun LeaseSuccessDetails(liquidityDetails: ChannelCommand.Commitment.Splice.Response.Created) { +private fun LiquiditySuccessDetails(liquidityDetails: ChannelFundingResponse.Success) { SuccessMessage( header = stringResource(id = R.string.liquidityads_success), - details = liquidityDetails.liquidityLease?.amount?.let { + details = liquidityDetails.liquidityPurchase?.amount?.let { stringResource(id = R.string.liquidityads_success_amount, it.toPrettyString(unit = LocalBitcoinUnit.current, withUnit = true)) }, alignment = Alignment.CenterHorizontally, diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityViewModel.kt index 175f83ef9..14b926a85 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityViewModel.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/liquidity/RequestLiquidityViewModel.kt @@ -23,9 +23,10 @@ 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.lightning.channel.ChannelFundingResponse import fr.acinq.lightning.channel.ChannelManagementFees +import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.phoenix.managers.AppConfigurationManager -import fr.acinq.phoenix.managers.NodeParamsManager import fr.acinq.phoenix.managers.PeerManager import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers @@ -38,16 +39,17 @@ import org.slf4j.LoggerFactory sealed class RequestLiquidityState { object Init: RequestLiquidityState() object Estimating: RequestLiquidityState() - data class Estimation(val amount: Satoshi, val fees: ChannelManagementFees, val actualFeerate: FeeratePerKw): RequestLiquidityState() + data class Estimation(val amount: Satoshi, val fees: ChannelManagementFees, val actualFeerate: FeeratePerKw, val fundingRate: LiquidityAds.FundingRate): RequestLiquidityState() object Requesting: RequestLiquidityState() sealed class Complete: RequestLiquidityState() { - abstract val response: ChannelCommand.Commitment.Splice.Response - data class Success(override val response: ChannelCommand.Commitment.Splice.Response.Created): Complete() - data class Failed(override val response: ChannelCommand.Commitment.Splice.Response.Failure): Complete() + abstract val response: ChannelFundingResponse + data class Success(override val response: ChannelFundingResponse.Success): Complete() + data class Failed(override val response: ChannelFundingResponse.Failure): Complete() } sealed class Error: RequestLiquidityState() { data class Thrown(val cause: Throwable): Error() - object NoChannelsAvailable: Error() + data object NoChannelsAvailable: Error() + data object InvalidFundingAmount: Error() } } @@ -65,23 +67,29 @@ class RequestLiquidityViewModel(val peerManager: PeerManager, val appConfigManag }) { val peer = peerManager.getPeer() val feerate = appConfigManager.mempoolFeerate.filterNotNull().first().hour + val fundingRate = peer.remoteFundingRates.filterNotNull().first().findRate(amount) + if (fundingRate == null) { + state.value = RequestLiquidityState.Error.InvalidFundingAmount + return@launch + } + peer.estimateFeeForInboundLiquidity( amount = amount, targetFeerate = FeeratePerKw(feerate), - leaseRate = NodeParamsManager.liquidityLeaseRate(amount), + fundingRate = fundingRate, ).let { response -> state.value = when (response) { null -> RequestLiquidityState.Error.NoChannelsAvailable else -> { val (actualFeerate, fees) = response - RequestLiquidityState.Estimation(amount, fees, actualFeerate) + RequestLiquidityState.Estimation(amount, fees, actualFeerate, fundingRate) } } } } } - fun requestInboundLiquidity(amount: Satoshi, feerate: FeeratePerKw) { + fun requestInboundLiquidity(amount: Satoshi, feerate: FeeratePerKw, fundingRate: LiquidityAds.FundingRate) { if (state.value is RequestLiquidityState.Requesting) return state.value = RequestLiquidityState.Requesting viewModelScope.launch(Dispatchers.Default + CoroutineExceptionHandler { _, e -> @@ -92,12 +100,12 @@ class RequestLiquidityViewModel(val peerManager: PeerManager, val appConfigManag peer.requestInboundLiquidity( amount = amount, feerate = feerate, - leaseRate = NodeParamsManager.liquidityLeaseRate(amount), + fundingRate = fundingRate, ).let { response -> state.value = when (response) { null -> RequestLiquidityState.Error.NoChannelsAvailable - is ChannelCommand.Commitment.Splice.Response.Failure -> RequestLiquidityState.Complete.Failed(response) - is ChannelCommand.Commitment.Splice.Response.Created -> RequestLiquidityState.Complete.Success(response) + is ChannelFundingResponse.Failure -> RequestLiquidityState.Complete.Failed(response) + is ChannelFundingResponse.Success -> RequestLiquidityState.Complete.Success(response) } } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/offer/SendOfferView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/offer/SendOfferView.kt index 25ea4a60b..6e4e583cc 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/offer/SendOfferView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/offer/SendOfferView.kt @@ -42,6 +42,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -64,7 +65,7 @@ import fr.acinq.phoenix.android.components.SplashLayout import fr.acinq.phoenix.android.components.TextInput import fr.acinq.phoenix.android.components.contact.ContactOrOfferView import fr.acinq.phoenix.android.components.feedback.ErrorMessage -import fr.acinq.phoenix.android.payments.details.translatePaymentError +import fr.acinq.phoenix.android.payments.details.splash.translatePaymentError import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.Converter.toPrettyString @@ -78,6 +79,7 @@ fun SendOfferView( val context = LocalContext.current val balance = business.balanceManager.balance.collectAsState(null).value val prefBitcoinUnit = LocalBitcoinUnit.current + val keyboardManager = LocalSoftwareKeyboardController.current val vm = viewModel(factory = SendOfferViewModel.Factory(offer, business.peerManager, business.nodeParamsManager, business.contactsManager)) val requestedAmount = offer.amount @@ -171,7 +173,7 @@ fun SendOfferView( } if (showMessageDialog) { - PayerNoteInput(initialMessage = message, onMessageChange = { message = it }, onDismiss = { showMessageDialog = false }) + PayerNoteInput(initialMessage = message, onMessageChange = { message = it }, onDismiss = { showMessageDialog = false ; keyboardManager?.hide() }) } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveViewModel.kt index f93b03133..860839e52 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveViewModel.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveViewModel.kt @@ -131,11 +131,9 @@ class ReceiveViewModel( val startAddress = keyManager.swapInOnChainWallet.getSwapInProtocol(startIndex).address(chain) val image = BitmapHelper.generateBitmap(startAddress).asImageBitmap() currentSwapAddress = BitcoinAddressState.Show(startIndex, startAddress, image) - log.info("starting with swap-in address $startAddress:$startIndex") // monitor the actual address from the swap-in wallet -- might take some time since the wallet must check all previous addresses peerManager.getPeer().phoenixSwapInWallet.swapInAddressFlow.filterNotNull().collect { (newAddress, newIndex) -> - log.info("swap-in wallet current address update: $newAddress:$newIndex") val newImage = BitmapHelper.generateBitmap(newAddress).asImageBitmap() internalDataRepository.saveLastUsedSwapIndex(newIndex) currentSwapAddress = BitcoinAddressState.Show(newIndex, newAddress, newImage) 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 87d9334fc..502634b50 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 @@ -43,6 +43,7 @@ import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.blockchain.fee.FeeratePerByte import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.ChannelCommand +import fr.acinq.lightning.channel.ChannelFundingResponse import fr.acinq.lightning.utils.sat import fr.acinq.lightning.utils.toMilliSatoshi import fr.acinq.phoenix.android.LocalBitcoinUnit @@ -417,17 +418,19 @@ private fun ReviewSpliceOutAndConfirm( } @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.ConcurrentRemoteSplice -> stringResource(id = R.string.splice_error_concurrent_remote) - is ChannelCommand.Commitment.Splice.Response.Failure.ChannelNotQuiescent -> stringResource(id = R.string.splice_error_channel_not_quiescent) - 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()) +fun spliceFailureDetails(spliceFailure: ChannelFundingResponse.Failure): String = when (spliceFailure) { + is ChannelFundingResponse.Failure.AbortedByPeer -> stringResource(id = R.string.splice_error_aborted_by_peer, spliceFailure.reason) + is ChannelFundingResponse.Failure.CannotCreateCommitTx -> stringResource(id = R.string.splice_error_cannot_create_commit) + is ChannelFundingResponse.Failure.ConcurrentRemoteSplice -> stringResource(id = R.string.splice_error_concurrent_remote) + is ChannelFundingResponse.Failure.ChannelNotQuiescent -> stringResource(id = R.string.splice_error_channel_not_quiescent) + is ChannelFundingResponse.Failure.Disconnected -> stringResource(id = R.string.splice_error_disconnected) + is ChannelFundingResponse.Failure.FundingFailure -> stringResource(id = R.string.splice_error_funding_error, spliceFailure.reason.javaClass.simpleName) + is ChannelFundingResponse.Failure.InsufficientFunds -> stringResource(id = R.string.splice_error_insufficient_funds) + is ChannelFundingResponse.Failure.CannotStartSession -> stringResource(id = R.string.splice_error_cannot_start_session) + is ChannelFundingResponse.Failure.InteractiveTxSessionFailed -> stringResource(id = R.string.splice_error_interactive_session, spliceFailure.reason.javaClass.simpleName) + is ChannelFundingResponse.Failure.InvalidSpliceOutPubKeyScript -> stringResource(id = R.string.splice_error_invalid_pubkey) + is ChannelFundingResponse.Failure.SpliceAlreadyInProgress -> stringResource(id = R.string.splice_error_splice_in_progress) + is ChannelFundingResponse.Failure.InvalidLiquidityAds -> stringResource(id = R.string.splice_error_invalid_liquidity_ads, spliceFailure.reason.details()) + is ChannelFundingResponse.Failure.InvalidChannelParameters -> stringResource(id = R.string.splice_error_invalid_channel_params, spliceFailure.reason.details()) + is ChannelFundingResponse.Failure.UnexpectedMessage -> stringResource(id = R.string.splice_error_unexpected, spliceFailure.msg.type.toString()) } 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 452b7d42f..b4d95caf3 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 @@ -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.channel.ChannelFundingResponse import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat import fr.acinq.phoenix.managers.PeerManager @@ -45,9 +46,9 @@ sealed class SpliceOutState { sealed class Complete: SpliceOutState() { abstract val userAmount: Satoshi abstract val feerate: FeeratePerKw - abstract val result: ChannelCommand.Commitment.Splice.Response - data class Success(override val userAmount: Satoshi, override val feerate: FeeratePerKw, override val result: ChannelCommand.Commitment.Splice.Response.Created): Complete() - data class Failure(override val userAmount: Satoshi, override val feerate: FeeratePerKw, override val result: ChannelCommand.Commitment.Splice.Response.Failure): Complete() + abstract val result: ChannelFundingResponse + data class Success(override val userAmount: Satoshi, override val feerate: FeeratePerKw, override val result: ChannelFundingResponse.Success): Complete() + data class Failure(override val userAmount: Satoshi, override val feerate: FeeratePerKw, override val result: ChannelFundingResponse.Failure): Complete() } sealed class Error: SpliceOutState() { data class Thrown(val e: Throwable): Error() @@ -70,7 +71,7 @@ class SpliceOutViewModel(private val peerManager: PeerManager, private val chain state = SpliceOutState.Error.Thrown(e) }) { state = SpliceOutState.Preparing(userAmount = amount, feeratePerByte = feeratePerByte) - log.debug("preparing splice-out for amount=$amount feerate=${feeratePerByte}sat/vb address=$address") + log.debug("preparing splice-out for amount={} feerate={}sat/vb address={}", amount, feeratePerByte, address) val userFeerate = FeeratePerKw(FeeratePerByte(feeratePerByte)) val scriptPubKey = Parser.addressToPublicKeyScriptOrNull(chain, address)!! val res = peerManager.getPeer().estimateFeeForSpliceOut( @@ -100,7 +101,7 @@ class SpliceOutViewModel(private val peerManager: PeerManager, private val chain ) { if (state is SpliceOutState.ReadyToSend) { state = SpliceOutState.Executing(amount, feerate) - log.debug("executing splice-out with for=$amount feerate=${feerate}sat/vb address=$address") + log.debug("executing splice-out with for={} feerate={}sat/vb address={}", amount, feerate, address) viewModelScope.launch(Dispatchers.Default + CoroutineExceptionHandler { _, e -> log.error("error when executing splice-out: ", e) state = SpliceOutState.Error.Thrown(e) @@ -111,11 +112,11 @@ class SpliceOutViewModel(private val peerManager: PeerManager, private val chain feerate = feerate, ) when (response) { - is ChannelCommand.Commitment.Splice.Response.Created -> { + is ChannelFundingResponse.Success -> { log.info("successfully executed splice-out: $response") state = SpliceOutState.Complete.Success(amount, feerate, response) } - is ChannelCommand.Commitment.Splice.Response.Failure -> { + is ChannelFundingResponse.Failure -> { log.info("failed to execute splice-out: $response") state = SpliceOutState.Complete.Failure(amount, feerate, response) } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/DailyConnect.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/DailyConnect.kt index 5a9d7be30..cd26c3117 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/DailyConnect.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/DailyConnect.kt @@ -39,6 +39,7 @@ import fr.acinq.phoenix.android.security.SeedManager import fr.acinq.phoenix.legacy.utils.LegacyAppStatus import fr.acinq.phoenix.legacy.utils.LegacyPrefsDatastore import fr.acinq.phoenix.managers.AppConnectionsDaemon +import fr.acinq.phoenix.utils.PlatformContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -53,14 +54,11 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.slf4j.LoggerFactory import java.util.concurrent.TimeUnit -import kotlin.time.Duration.Companion.minutes -import kotlin.time.toJavaDuration /** * This worker is scheduled to run roughly every day. It simply connects to the LSP, wait for 1 minute, * then shuts down. The purpose is to settle pending payments that may have been missed by the * [InflightPaymentsWatcher], to complete closings properly, etc... - * */ class DailyConnect(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { @@ -113,6 +111,7 @@ class DailyConnect(context: Context, workerParams: WorkerParameters) : Coroutine var jobWatchingChannels: Job? = null val jobMain = launch { + business = PhoenixBusiness(PlatformContext(applicationContext)) service.filterNotNull().flatMapLatest { it.state.asFlow() }.collect { state -> when (state) { is NodeServiceState.Init, is NodeServiceState.Running, is NodeServiceState.Error, NodeServiceState.Disconnected -> { @@ -126,7 +125,7 @@ class DailyConnect(context: Context, workerParams: WorkerParameters) : Coroutine log.info("node service in state=${state.name}, starting an isolated business") jobWatchingChannels = launch { - business = WorkerHelper.startIsolatedBusiness(application, encryptedSeed, userPrefs) + WorkerHelper.startIsolatedBusiness(application, business!!, encryptedSeed, userPrefs) business?.connectionsManager?.connections?.first { it.global is Connection.ESTABLISHED } log.debug("connections established") @@ -184,8 +183,8 @@ class DailyConnect(context: Context, workerParams: WorkerParameters) : Coroutine } fun scheduleASAP(context: Context) { - log.info("scheduling $name") - val work = OneTimeWorkRequest.Builder(DailyConnect::class.java).setInitialDelay(1.minutes.toJavaDuration()).addTag(TAG).build() + log.info("scheduling $name once") + val work = OneTimeWorkRequest.Builder(DailyConnect::class.java).addTag(TAG).build() WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, work) } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/InflightPaymentsWatcher.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/InflightPaymentsWatcher.kt index 45ab1dace..51b6dc893 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/InflightPaymentsWatcher.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/InflightPaymentsWatcher.kt @@ -134,6 +134,7 @@ class InflightPaymentsWatcher(context: Context, workerParams: WorkerParameters) // Start the monitoring process. If the main app starts, we interrupt this job to prevent concurrent access. withContext(Dispatchers.Default) { + business = PhoenixBusiness(PlatformContext(applicationContext)) val stopJobs = MutableStateFlow(false) var jobChannelsWatcher: Job? = null @@ -152,7 +153,7 @@ class InflightPaymentsWatcher(context: Context, workerParams: WorkerParameters) log.info("node service in state=${state.name}, starting an isolated business") jobChannelsWatcher = launch { - business = WorkerHelper.startIsolatedBusiness(application, encryptedSeed, userPrefs) + WorkerHelper.startIsolatedBusiness(application, business!!, encryptedSeed, userPrefs) business?.connectionsManager?.connections?.first { it.global is Connection.ESTABLISHED } log.debug("connections established, watching channels for in-flight payments...") diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt index 9795b4986..91bf81f44 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt @@ -21,7 +21,7 @@ import com.google.firebase.messaging.FirebaseMessaging import fr.acinq.bitcoin.TxId import fr.acinq.lightning.LiquidityEvents import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.io.PaymentReceived +import fr.acinq.lightning.PaymentEvents import fr.acinq.lightning.utils.Connection import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.phoenix.PhoenixBusiness @@ -257,7 +257,7 @@ class NodeService : Service() { val trustedSwapInTxs = LegacyPrefsDatastore.getMigrationTrustedSwapInTxs(applicationContext).first() val preferredFiatCurrency = userPrefs.getFiatCurrency.first() - monitorPaymentsJob = serviceScope.launch { monitorPaymentsWhenHeadless(business.peerManager, business.currencyManager, userPrefs) } + monitorPaymentsJob = serviceScope.launch { monitorPaymentsWhenHeadless(business.nodeParamsManager, business.currencyManager, userPrefs) } monitorNodeEventsJob = serviceScope.launch { monitorNodeEvents(business.peerManager, business.nodeParamsManager) } monitorFcmTokenJob = serviceScope.launch { monitorFcmToken(business) } monitorInFlightPaymentsJob = serviceScope.launch { monitorInFlightPayments(business.peerManager) } @@ -300,7 +300,7 @@ class NodeService : Service() { // TODO: click on notif must deeplink to the notification screen when (event) { is LiquidityEvents.Rejected -> { - log.debug("processing liquidity_event=$event") + log.debug("processing liquidity_event={}", event) if (event.source == LiquidityEvents.Source.OnChainWallet) { // Check the last time a rejected on-chain swap notification has been shown. If recent, we do not want to trigger a notification every time. val lastRejectedSwap = internalData.getLastRejectedOnchainSwap.first().takeIf { @@ -327,8 +327,14 @@ class NodeService : Service() { is LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee -> { SystemNotificationHelper.notifyPaymentRejectedOverRelative(applicationContext, event.source, event.amount, event.fee, reason.maxRelativeFeeBasisPoints, nextTimeout?.second) } - LiquidityEvents.Rejected.Reason.ChannelInitializing -> { - SystemNotificationHelper.notifyPaymentRejectedChannelsInitializing(applicationContext, event.source, event.amount, nextTimeout?.second) + is LiquidityEvents.Rejected.Reason.MissingOffChainAmountTooLow -> { + SystemNotificationHelper.notifyPaymentRejectedAmountTooLow(applicationContext, event.source, event.amount) + } + // Temporary errors + is LiquidityEvents.Rejected.Reason.ChannelFundingInProgress, + is LiquidityEvents.Rejected.Reason.NoMatchingFundingRate, + is LiquidityEvents.Rejected.Reason.TooManyParts -> { + SystemNotificationHelper.notifyPaymentRejectedFundingError(applicationContext, event.source, event.amount) } } } @@ -337,17 +343,18 @@ class NodeService : Service() { } } - private suspend fun monitorPaymentsWhenHeadless(peerManager: PeerManager, currencyManager: CurrencyManager, userPrefs: UserPrefsRepository) { - peerManager.getPeer().eventsFlow.collect { event -> + private suspend fun monitorPaymentsWhenHeadless(nodeParamsManager: NodeParamsManager, currencyManager: CurrencyManager, userPrefs: UserPrefsRepository) { + + nodeParamsManager.nodeParams.filterNotNull().first().nodeEvents.collect { event -> when (event) { - is PaymentReceived -> { + is PaymentEvents.PaymentReceived -> { if (isHeadless) { - receivedInBackground.add(event.received.amount) + receivedInBackground.add(event.amount) SystemNotificationHelper.notifyPaymentsReceived( context = applicationContext, userPrefs = userPrefs, - paymentHash = event.incomingPayment.paymentHash, - amount = event.received.amount, + paymentHash = event.paymentHash, + amount = event.amount, rates = currencyManager.ratesFlow.value, isHeadless = isHeadless && receivedInBackground.size == 1 ) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/WorkerHelper.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/WorkerHelper.kt index 05a5251d6..639f767a6 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/WorkerHelper.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/WorkerHelper.kt @@ -25,15 +25,13 @@ import fr.acinq.phoenix.data.StartupParams import fr.acinq.phoenix.legacy.utils.LegacyPrefsDatastore import fr.acinq.phoenix.managers.AppConfigurationManager import fr.acinq.phoenix.utils.MnemonicLanguage -import fr.acinq.phoenix.utils.PlatformContext import kotlinx.coroutines.flow.first object WorkerHelper { - suspend fun startIsolatedBusiness(context: Context, encryptedSeed: EncryptedSeed.V2.NoAuth, userPrefs: UserPrefsRepository): PhoenixBusiness { + suspend fun startIsolatedBusiness(context: Context, business: PhoenixBusiness, encryptedSeed: EncryptedSeed.V2.NoAuth, userPrefs: UserPrefsRepository) { val mnemonics = encryptedSeed.decrypt() // retrieve preferences before starting business - val business = PhoenixBusiness(PlatformContext(context)) val electrumServer = userPrefs.getElectrumServer.first() val isTorEnabled = userPrefs.getIsTorEnabled.first() val liquidityPolicy = userPrefs.getLiquidityPolicy.first() @@ -60,6 +58,5 @@ object WorkerHelper { // start the swap-in wallet watcher business.peerManager.getPeer().startWatchSwapInWallet() - return business } } \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/NotificationsView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/NotificationsView.kt index ee7b80484..86f7130ab 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/NotificationsView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/NotificationsView.kt @@ -268,14 +268,13 @@ private fun PaymentNotification( notification.fee.toPrettyString(btcUnit, withUnit = true), notification.maxAbsoluteFee.toPrettyString(btcUnit, withUnit = true), ) - is Notification.OverRelativeFee -> stringResource( id = R.string.inappnotif_payment_rejected_over_relative, notification.fee.toPrettyString(btcUnit, withUnit = true), DecimalFormat("0.##").format(notification.maxRelativeFeeBasisPoints.toDouble() / 100), ) - - is Notification.ChannelsInitializing -> stringResource(id = R.string.inappnotif_payment_rejected_channel_initializing) + is Notification.MissingOffChainAmountTooLow -> stringResource(id = R.string.notif_rejected_amount_too_low) + is Notification.GenericError -> stringResource(id = R.string.notif_rejected_generic_error) }, bottomText = when (notification) { is Notification.OverAbsoluteFee, is Notification.OverRelativeFee, is Notification.FeePolicyDisabled -> { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ResetWallet.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ResetWallet.kt index 19574ecd6..26bfea7b1 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ResetWallet.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ResetWallet.kt @@ -64,6 +64,7 @@ import fr.acinq.phoenix.android.fiatRate import fr.acinq.phoenix.android.security.SeedManager import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.negativeColor +import fr.acinq.phoenix.utils.extensions.phoenixName import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -115,8 +116,8 @@ class ResetWalletViewModel : ViewModel() { state.value = ResetWalletStep.Deleting.Databases context.deleteDatabase("appdb.sqlite") - context.deleteDatabase("payments-${chain.name.lowercase()}-$nodeIdHash.sqlite") - context.deleteDatabase("channels-${chain.name.lowercase()}-$nodeIdHash.sqlite") + context.deleteDatabase("payments-${chain.phoenixName}-$nodeIdHash.sqlite") + context.deleteDatabase("channels-${chain.phoenixName}-$nodeIdHash.sqlite") delay(500) state.value = ResetWalletStep.Deleting.Prefs 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 0a644d62a..4fd54aee2 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 @@ -68,7 +68,7 @@ import fr.acinq.phoenix.android.components.PhoenixIcon import fr.acinq.phoenix.android.components.ProgressView import fr.acinq.phoenix.android.components.settings.Setting import fr.acinq.phoenix.android.components.settings.SettingWithCopy -import fr.acinq.phoenix.android.components.TransactionLinkButton +import fr.acinq.phoenix.android.components.InlineTransactionLink import fr.acinq.phoenix.android.navController import fr.acinq.phoenix.android.navigateToPaymentDetails import fr.acinq.phoenix.android.utils.Converter.toPrettyString @@ -188,7 +188,7 @@ private fun CommitmentDetailsView( Row { Text(text = stringResource(id = R.string.channeldetails_commitment_funding_tx_id), modifier = Modifier.alignByBaseline()) Spacer(modifier = Modifier.width(4.dp)) - TransactionLinkButton(txId = commitment.fundingTxId, modifier = Modifier.alignByBaseline()) + InlineTransactionLink(txId = commitment.fundingTxId, modifier = Modifier.alignByBaseline()) } Row { Text(text = stringResource(id = R.string.channeldetails_commitment_balance)) 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 fc9207aea..bcdc02da2 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 @@ -111,7 +111,7 @@ private fun LightningBalanceView( if (balance != null && inboundLiquidity != null) { val balanceVsInbound = remember(balance, inboundLiquidity) { (balance.msat.toFloat() / (balance.msat + inboundLiquidity.msat)) - .coerceIn(0.1f, 0.9f)// unreadable otherwise + .coerceIn(0.1f, if (inboundLiquidity.msat > 0) 0.9f else 1f) // unreadable otherwise .takeUnless { it.isNaN() } } Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/AdvancedIncomingFeePolicy.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/AdvancedIncomingFeePolicy.kt index 9aea96eef..bab90ef09 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/AdvancedIncomingFeePolicy.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/AdvancedIncomingFeePolicy.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import fr.acinq.lightning.payment.LiquidityPolicy +import fr.acinq.lightning.utils.msat import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.business import fr.acinq.phoenix.android.components.Button @@ -111,7 +112,15 @@ fun AdvancedIncomingFeePolicy( } Card { - val newPolicy = maxRelativeFeeBasisPoints?.let { LiquidityPolicy.Auto(maxRelativeFeeBasisPoints = it, maxAbsoluteFee = maxAbsoluteFee, skipAbsoluteFeeCheck = skipAbsoluteFeeCheck) } + val newPolicy = maxRelativeFeeBasisPoints?.let { + LiquidityPolicy.Auto( + inboundLiquidityTarget = null, + maxRelativeFeeBasisPoints = it, + maxAbsoluteFee = maxAbsoluteFee, + skipAbsoluteFeeCheck = skipAbsoluteFeeCheck, + maxAllowedFeeCredit = 0.msat, + ) + } val isEnabled = newPolicy != null && liquidityPolicyPrefs != newPolicy Button( text = stringResource(id = R.string.liquiditypolicy_save_button), diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/LiquidityPolicyView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/LiquidityPolicyView.kt index 302d0086a..c74b511c4 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/LiquidityPolicyView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/LiquidityPolicyView.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.payment.LiquidityPolicy +import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat import fr.acinq.phoenix.android.LocalFiatCurrency import fr.acinq.phoenix.android.R @@ -132,7 +133,15 @@ fun LiquidityPolicyView( val skipAbsoluteFeeCheck = if (liquidityPolicyPrefs is LiquidityPolicy.Auto) liquidityPolicyPrefs.skipAbsoluteFeeCheck else false val newPolicy = when { isPolicyDisabled -> LiquidityPolicy.Disable - else -> maxAbsoluteFee?.let { LiquidityPolicy.Auto(maxRelativeFeeBasisPoints = maxPropFeePrefs, maxAbsoluteFee = it, skipAbsoluteFeeCheck = skipAbsoluteFeeCheck) } + else -> maxAbsoluteFee?.let { + LiquidityPolicy.Auto( + inboundLiquidityTarget = null, + maxRelativeFeeBasisPoints = maxPropFeePrefs, + maxAbsoluteFee = it, + skipAbsoluteFeeCheck = skipAbsoluteFeeCheck, + maxAllowedFeeCredit = 0.msat, + ) + } } val isEnabled = newPolicy != null && liquidityPolicyPrefs != newPolicy Button( diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInRefundView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInRefundView.kt index 0d4971a8e..121aa910b 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInRefundView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInRefundView.kt @@ -23,12 +23,10 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -49,7 +47,6 @@ 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 androidx.lifecycle.viewmodel.compose.viewModel import com.journeyapps.barcodescanner.DecoratedBarcodeView import fr.acinq.bitcoin.Satoshi @@ -71,7 +68,7 @@ import fr.acinq.phoenix.android.components.FeerateSlider import fr.acinq.phoenix.android.components.ProgressView import fr.acinq.phoenix.android.components.SplashLabelRow import fr.acinq.phoenix.android.components.TextInput -import fr.acinq.phoenix.android.components.TransactionLinkButton +import fr.acinq.phoenix.android.components.InlineTransactionLink import fr.acinq.phoenix.android.components.feedback.ErrorMessage import fr.acinq.phoenix.android.components.feedback.SuccessMessage import fr.acinq.phoenix.android.fiatRate @@ -79,7 +76,6 @@ import fr.acinq.phoenix.android.payments.CameraPermissionsView import fr.acinq.phoenix.android.payments.ScannerView import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.annotatedStringResource -import fr.acinq.phoenix.android.utils.copyToClipboard import fr.acinq.phoenix.managers.PeerManager import fr.acinq.phoenix.utils.Parser @@ -276,7 +272,7 @@ private fun AvailableForRefundView( Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { Text(text = stringResource(id = R.string.swapinrefund_success_details)) Spacer(modifier = Modifier.height(12.dp)) - TransactionLinkButton(txId = currentState.tx.txid) + InlineTransactionLink(txId = currentState.tx.txid) } } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInWalletInfo.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInWalletInfo.kt index e7c59b83c..c96e9efd5 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInWalletInfo.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInWalletInfo.kt @@ -237,7 +237,8 @@ private fun ReadyForSwapView( DecimalFormat("0.##").format(lastSwapFailedNotification.maxRelativeFeeBasisPoints.toDouble() / 100), ) is Notification.FeePolicyDisabled -> stringResource(id = R.string.walletinfo_onchain_swapin_last_attempt_disabled) - is Notification.ChannelsInitializing -> stringResource(id = R.string.walletinfo_onchain_swapin_last_attempt_channels_init) + is Notification.MissingOffChainAmountTooLow -> stringResource(id = R.string.notif_rejected_amount_too_low) + is Notification.GenericError -> stringResource(id = R.string.notif_rejected_generic_error) }, ) } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/startup/StartupViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/startup/StartupViewModel.kt index 02cc30771..58d7b499f 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/startup/StartupViewModel.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/startup/StartupViewModel.kt @@ -28,6 +28,7 @@ import fr.acinq.phoenix.android.security.SeedManager import fr.acinq.phoenix.android.services.NodeService import fr.acinq.phoenix.managers.NodeParamsManager import fr.acinq.phoenix.managers.nodeIdHash +import fr.acinq.phoenix.utils.extensions.phoenixName import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -98,7 +99,7 @@ class StartupViewModel : ViewModel() { val seed = MnemonicCode.toSeed(mnemonics = words.joinToString(" "), passphrase = "").byteVector() val localKeyManager = LocalKeyManager(seed = seed, chain = NodeParamsManager.chain, remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub) val nodeIdHash = localKeyManager.nodeIdHash() - val channelsDbFile = context.getDatabasePath("channels-${NodeParamsManager.chain.name.lowercase()}-$nodeIdHash.sqlite") + val channelsDbFile = context.getDatabasePath("channels-${NodeParamsManager.chain.phoenixName}-$nodeIdHash.sqlite") if (channelsDbFile.exists()) { decryptionState.value = StartupDecryptionState.SeedInputFallback.Success.MatchingData val encodedSeed = EncryptedSeed.fromMnemonics(words) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/LegacyMigrationHelper.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/LegacyMigrationHelper.kt index b271c424c..82ebe8cea 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/LegacyMigrationHelper.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/LegacyMigrationHelper.kt @@ -46,6 +46,7 @@ import fr.acinq.phoenix.data.BitcoinUnit import fr.acinq.phoenix.data.FiatCurrency import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.data.WalletPaymentInfo +import fr.acinq.phoenix.data.WalletPaymentMetadata import fr.acinq.phoenix.data.lnurl.LnurlAuth import fr.acinq.phoenix.legacy.db.* import fr.acinq.phoenix.legacy.utils.Prefs @@ -305,7 +306,7 @@ object LegacyMigrationHelper { // use the PayToOpen metadata to know how the payment was received val receivedWith = if (payToOpenMeta != null || payment.paymentType() == PaymentType.SwapIn()) { IncomingPayment.ReceivedWith.NewChannel( - amount = status.amount().toLong().msat, + amountReceived = status.amount().toLong().msat, serviceFee = payToOpenMeta?.fee_sat?.sat?.toMilliSatoshi() ?: 0.msat, miningFee = 0.sat, channelId = ByteVector32.Zeroes, @@ -315,9 +316,10 @@ object LegacyMigrationHelper { ) } else { IncomingPayment.ReceivedWith.LightningPayment( - amount = status.amount().toLong().msat, + amountReceived = status.amount().toLong().msat, channelId = ByteVector32.Zeroes, - htlcId = 0L + htlcId = 0L, + fundingFee = null, ) } @@ -494,12 +496,11 @@ object LegacyMigrationHelper { } /** Returns true if the payment is a channel-close made by the legacy app to the node's swap-in address. Uses the [LegacyMigrationHelper.migrationDescFlag] metadata flag. */ -fun WalletPaymentInfo.isLegacyMigration(peer: Peer?): Boolean? { - val p = payment +fun WalletPayment.isLegacyMigration(metadata: WalletPaymentMetadata, peer: Peer?): Boolean? { return when { - p !is ChannelCloseOutgoingPayment -> false + this !is ChannelCloseOutgoingPayment -> false peer == null -> null - p.address == peer.phoenixSwapInWallet.legacySwapInAddress && metadata.userDescription == LegacyMigrationHelper.migrationDescFlag -> true + this.address == peer.phoenixSwapInWallet.legacySwapInAddress && metadata.userDescription == LegacyMigrationHelper.migrationDescFlag -> true else -> false } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/SystemNotificationHelper.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/SystemNotificationHelper.kt index c4bd04a40..9326ba525 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/SystemNotificationHelper.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/SystemNotificationHelper.kt @@ -151,16 +151,6 @@ object SystemNotificationHelper { ) } - fun notifyPaymentRejectedChannelsInitializing(context: Context, source: LiquidityEvents.Source, amountIncoming: MilliSatoshi, nextTimeoutRemainingBlocks: Int?): Notification { - return notifyPaymentFailed( - context = context, - title = context.getString(if (source == LiquidityEvents.Source.OnChainWallet) R.string.notif_rejected_deposit_title else R.string.notif_rejected_payment_title, - amountIncoming.toPrettyString(BitcoinUnit.Sat, withUnit = true)), - message = context.getString(R.string.notif_rejected_channels_initializing), - deepLink = if (source == LiquidityEvents.Source.OnChainWallet) "phoenix:swapinwallet" else "phoenix:liquiditypolicy", - ) - } - fun notifyPaymentRejectedOverAbsolute(context: Context, source: LiquidityEvents.Source, amountIncoming: MilliSatoshi, fee: MilliSatoshi, absoluteMax: Satoshi, nextTimeoutRemainingBlocks: Int?): Notification { return notifyPaymentFailed( context = context, @@ -203,6 +193,26 @@ object SystemNotificationHelper { ) } + fun notifyPaymentRejectedAmountTooLow(context: Context, source: LiquidityEvents.Source, amountIncoming: MilliSatoshi): Notification { + return notifyPaymentFailed( + context = context, + title = context.getString(if (source == LiquidityEvents.Source.OnChainWallet) R.string.notif_rejected_deposit_title else R.string.notif_rejected_payment_title, + amountIncoming.toPrettyString(BitcoinUnit.Sat, withUnit = true)), + message = context.getString(R.string.notif_rejected_amount_too_low), + deepLink = if (source == LiquidityEvents.Source.OnChainWallet) "phoenix:swapinwallet" else "phoenix:liquiditypolicy", + ) + } + + fun notifyPaymentRejectedFundingError(context: Context, source: LiquidityEvents.Source, amountIncoming: MilliSatoshi): Notification { + return notifyPaymentFailed( + context = context, + title = context.getString(if (source == LiquidityEvents.Source.OnChainWallet) R.string.notif_rejected_deposit_title else R.string.notif_rejected_payment_title, + amountIncoming.toPrettyString(BitcoinUnit.Sat, withUnit = true)), + message = context.getString(R.string.notif_rejected_generic_error), + deepLink = if (source == LiquidityEvents.Source.OnChainWallet) "phoenix:swapinwallet" else "phoenix:liquiditypolicy", + ) + } + fun notifyPaymentMissedAppUnavailable(context: Context): Notification { return notifyPaymentFailed( context = context, diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefsRepository.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefsRepository.kt index 842436df3..e82e737d3 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefsRepository.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefsRepository.kt @@ -25,6 +25,7 @@ import fr.acinq.lightning.TrampolineFees import fr.acinq.lightning.io.TcpSocket import fr.acinq.lightning.payment.LiquidityPolicy import fr.acinq.lightning.utils.ServerAddress +import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat import fr.acinq.phoenix.android.utils.UserTheme import fr.acinq.phoenix.data.BitcoinUnit @@ -214,7 +215,13 @@ class UserPrefsRepository(private val data: DataStore) { try { it[LIQUIDITY_POLICY]?.let { policy -> when (val res = json.decodeFromString(policy)) { - is InternalLiquidityPolicy.Auto -> LiquidityPolicy.Auto(res.maxAbsoluteFee, res.maxRelativeFeeBasisPoints, res.skipAbsoluteFeeCheck) + is InternalLiquidityPolicy.Auto -> LiquidityPolicy.Auto( + inboundLiquidityTarget = null, + maxAbsoluteFee = res.maxAbsoluteFee, + maxRelativeFeeBasisPoints = res.maxRelativeFeeBasisPoints, + skipAbsoluteFeeCheck = res.skipAbsoluteFeeCheck, + maxAllowedFeeCredit = 0.msat, + ) is InternalLiquidityPolicy.Disable -> LiquidityPolicy.Disable } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions.kt index d5ea31466..7dbb18673 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/extensions.kt @@ -17,22 +17,19 @@ package fr.acinq.phoenix.android.utils import android.content.* -import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.core.content.FileProvider import fr.acinq.lightning.db.* import fr.acinq.lightning.utils.Connection -import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.phoenix.android.* import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.data.BitcoinUnit import fr.acinq.phoenix.data.FiatCurrency import fr.acinq.phoenix.utils.extensions.desc -import java.io.File +import fr.acinq.phoenix.utils.extensions.isManualPurchase import java.security.cert.CertificateException import java.util.* import kotlin.contracts.ExperimentalContracts @@ -126,25 +123,64 @@ fun UserTheme.label(): String { fun Connection.CLOSED.isBadCertificate() = this.reason?.cause is CertificateException +@Composable +fun LightningOutgoingPayment.smartDescription(): String? = when (val details = this.details) { + is LightningOutgoingPayment.Details.Normal -> details.paymentRequest.desc + is LightningOutgoingPayment.Details.SwapOut -> stringResource(id = R.string.paymentdetails_desc_swapout, details.address) + is LightningOutgoingPayment.Details.Blinded -> details.paymentRequest.description +}?.takeIf { it.isNotBlank() } + +@Composable +fun SpliceOutgoingPayment.smartDescription(): String = stringResource(id = R.string.paymentdetails_desc_splice_out) + +@Composable +fun SpliceCpfpOutgoingPayment.smartDescription(): String = stringResource(id = R.string.paymentdetails_desc_cpfp) + +@Composable +fun ChannelCloseOutgoingPayment.smartDescription(): String = stringResource(id = R.string.paymentdetails_desc_closing_channel) + +@Composable +fun InboundLiquidityOutgoingPayment.smartDescription(): String = when { + isManualPurchase() -> stringResource(id = R.string.paymentdetails_desc_liquidity_manual, purchase.amount.toPrettyString(BitcoinUnit.Sat, withUnit = true)) + else -> stringResource(id = R.string.paymentdetails_desc_liquidity_automated) +} + +@Composable +fun IncomingPayment.smartDescription() : String? = when (val origin = this.origin) { + is IncomingPayment.Origin.Invoice -> origin.paymentRequest.description + is IncomingPayment.Origin.SwapIn, is IncomingPayment.Origin.OnChain -> stringResource(id = R.string.paymentdetails_desc_swapin) + is IncomingPayment.Origin.Offer -> null +}?.takeIf { it.isNotBlank() } + /** * Returns a trimmed, localized description of the payment, based on the type and information available. May be null! * * For example, a payment closing a channel has no description, and it's up to us to create one. Others like a LN * payment with an invoice do have a description baked in, and that's what is returned. */ -fun WalletPayment.smartDescription(context: Context): String? = when (this) { +@Composable +fun WalletPayment.smartDescription(): String? = when (this) { + is LightningOutgoingPayment -> smartDescription() + is IncomingPayment -> smartDescription() + is ChannelCloseOutgoingPayment -> smartDescription() + is SpliceOutgoingPayment -> smartDescription() + is SpliceCpfpOutgoingPayment -> smartDescription() + is InboundLiquidityOutgoingPayment -> smartDescription() +} + +fun WalletPayment.basicDescription(): String? = when (this) { is LightningOutgoingPayment -> when (val details = this.details) { is LightningOutgoingPayment.Details.Normal -> details.paymentRequest.desc - is LightningOutgoingPayment.Details.SwapOut -> context.getString(R.string.paymentdetails_desc_swapout, details.address) + is LightningOutgoingPayment.Details.SwapOut -> null is LightningOutgoingPayment.Details.Blinded -> details.paymentRequest.description } is IncomingPayment -> when (val origin = this.origin) { is IncomingPayment.Origin.Invoice -> origin.paymentRequest.description - is IncomingPayment.Origin.SwapIn, is IncomingPayment.Origin.OnChain -> context.getString(R.string.paymentdetails_desc_swapin) + is IncomingPayment.Origin.SwapIn, is IncomingPayment.Origin.OnChain -> null is IncomingPayment.Origin.Offer -> null - } - is SpliceOutgoingPayment -> context.getString(R.string.paymentdetails_desc_splice_out) - is ChannelCloseOutgoingPayment -> context.getString(R.string.paymentdetails_desc_closing_channel) - is SpliceCpfpOutgoingPayment -> context.getString(R.string.paymentdetails_desc_cpfp) - is InboundLiquidityOutgoingPayment -> context.getString(R.string.paymentdetails_desc_inbound_liquidity, lease.amount.toPrettyString(BitcoinUnit.Sat, withUnit = true)) + }?.takeIf { it.isNotBlank() } + is ChannelCloseOutgoingPayment -> null + is SpliceOutgoingPayment -> null + is SpliceCpfpOutgoingPayment -> null + is InboundLiquidityOutgoingPayment -> null }?.takeIf { it.isNotBlank() } \ No newline at end of file diff --git a/phoenix-android/src/main/res/values-b+es+419/important_strings.xml b/phoenix-android/src/main/res/values-b+es+419/important_strings.xml index 5e546b468..8287c0c1a 100644 --- a/phoenix-android/src/main/res/values-b+es+419/important_strings.xml +++ b/phoenix-android/src/main/res/values-b+es+419/important_strings.xml @@ -41,7 +41,8 @@ La comisión fue %1$s, pero el límite máximo se fijó en %2$s. Este depósito vencerá el %3$s. La comisión fue %1$s, que es más del %2$s%% del importe recibido. Toca para obtener más información. La comisión fue %1$s, que es más del %2$s%% del importe recibido. Este depósito vencerá el %3$s. - Los canales se estaban inicializando, por lo que no se pudo aceptar el pago. Intenta de nuevo más tarde. + El importe del pago es demasiado bajo. + Se ha producido un error durante la financiación. Vuelva a intentarlo más tarde. Inicia Phoenix Es posible que algunos de los canales se hayan cerrado. @@ -224,7 +225,6 @@ La gestión automatizada de canales está desactivada. La comisión fue %1$s, pero el límite máximo se fijó en %2$s. La comisión fue %1$s, que es más del %2$s%% del importe. - Los canales todavía se están inicializando, por lo que no se pudo recibir ese pago. Toca para configurar. Ver detalles @@ -246,6 +246,7 @@ + Liquidez Comisiones de minería Comisiones pagadas a los mineros de la red Bitcoin para procesar la transacción en la cadena. Comisiones de servicio @@ -254,8 +255,9 @@ Comisiones pagadas por el servicio de liquidez. Comisiones de minería Comisiones pagadas a los mineros de la red Bitcoin para procesar la transacción en la cadena. - Duración - 1 año + Causado por + Esta liquidez era necesaria para recibir un pago. + Vea cómo optimizar @@ -308,7 +310,6 @@ Error al intentar realizar el intercambio %1$s La gestión de canales estaba desactivada. - Los canales aún se estaban inicializando. Este intercambio vencerá en un día. Este intercambio vencerá en %1$s días. @@ -382,6 +383,7 @@ Error al solicitar liquidez Los canales no están disponibles. Vuelve a intentarlo más tarde. + El importe solicitado no es válido. diff --git a/phoenix-android/src/main/res/values-b+es+419/strings.xml b/phoenix-android/src/main/res/values-b+es+419/strings.xml index f737205ab..e7a46989f 100644 --- a/phoenix-android/src/main/res/values-b+es+419/strings.xml +++ b/phoenix-android/src/main/res/values-b+es+419/strings.xml @@ -330,7 +330,8 @@ Mensaje Desencriptando mensaje… - Desc. + Descripción + Nota Enviado a Mineros de Bitcoin Comisiones @@ -342,6 +343,8 @@ Canal de cierre Migración desde aplicación heredada Impulsar transacciones + Liquidez manual (+%1$s) + Liquidez automatizada Intercambiar a %1$s Depósito en la cadena @@ -446,7 +449,7 @@ Cambiar esquema Iniciar sesión Intentar de nuevo - Iniciando sesión en%1$s. + Iniciando sesión en\n%1$s. Autenticación correcta. Error de autenticación: Error de red. Comprueba tu conexión a Internet y vuelve a intentarlo. diff --git a/phoenix-android/src/main/res/values-cs/important_strings.xml b/phoenix-android/src/main/res/values-cs/important_strings.xml index 9f4a465c7..d762abf01 100644 --- a/phoenix-android/src/main/res/values-cs/important_strings.xml +++ b/phoenix-android/src/main/res/values-cs/important_strings.xml @@ -44,7 +44,8 @@ Poplatek činil %1$s, ale váš maximální poplatek byl nastaven na %2$s. Platnost této zálohy vyprší %3$s. Poplatek činil %1$s, což je více než %2$s%% z přijaté částky. Můžete to upravit v nastavení. Poplatek činil %1$s, což je více než %2$s%% z přijaté částky. Platnost této zálohy vyprší %3$s. - Vaše kanály se inicializovaly a nemohly tuto platbu přijmout. Zkuste to později. + Výše platby je příliš nízká. + Při financování došlo k chybě. Zkuste to prosím později. Spusťte prosím Phoenix Některý z vašich kanálů mohl být uzavřen. @@ -226,7 +227,6 @@ Automatická správa kanálů je zakázána. Poplatek byl %1$s, ale váš maximální poplatek byl nastaven na %2$s. Poplatek byl %1$s, což je více než %2$s%% částky. - Vaše kanály se stále inicializují a nemohly tuto platbu přijmout. Klepnutím na položku provedete konfiguraci. Zobrazit podrobnosti @@ -248,6 +248,7 @@ + Likvidity Poplatky těžařům Poplatky placené těžařům Bitcoinové sítě za zpracování On-Chain transakce. Poplatky za službu @@ -256,8 +257,9 @@ Poplatky za službu likvidity. Poplatky těžařům Poplatky placené těžařům Bitcoinové sítě za zpracování on-chain transakce. - Doba trvání - 1 rok + Způsobeno + Tato likvidita byla vyžadována pro získání platby. + Podívejte se, jak optimalizovat @@ -307,7 +309,6 @@ Automatizovaná poplatková politika je vypnutá. Prostředky nebudou vyměňovány. Zůstanou na této peněžence. Poslední pokus (%1$s). Automatizovaná poplatková politika je vypnutá. - Vaše kanály byly inicializovány. Neprobíhají žádné výměny. Platnost této výměny vyprší za den! @@ -393,6 +394,7 @@ Žádost o likviditu se nezdařila Kanály nejsou k dispozici. Zkuste to později. + Požadovaná částka je neplatná. diff --git a/phoenix-android/src/main/res/values-cs/strings.xml b/phoenix-android/src/main/res/values-cs/strings.xml index d847e2baa..caeb09af9 100644 --- a/phoenix-android/src/main/res/values-cs/strings.xml +++ b/phoenix-android/src/main/res/values-cs/strings.xml @@ -315,6 +315,7 @@ Dešifrování zprávy… Popis + Poznámka Odesláno Bitcoinoví těžaři Poplatky @@ -326,6 +327,8 @@ Zavírání kanálu Migrace ze staré aplikace Postrčit transakci + Manuální likvidita (+%1$s) + Automatizovaná likvidita Swap-out do %1$s On-Chain vklad diff --git a/phoenix-android/src/main/res/values-de/important_strings.xml b/phoenix-android/src/main/res/values-de/important_strings.xml index 08c413c06..7494866d5 100644 --- a/phoenix-android/src/main/res/values-de/important_strings.xml +++ b/phoenix-android/src/main/res/values-de/important_strings.xml @@ -41,7 +41,8 @@ Die Gebühr wäre %1$s, aber Ihr Gebührenlimit beträgt %2$s. Diese Einzahlung verfällt am %3$s. Die Gebühr wäre %1$s, was mehr als %2$s%% des zu empfangenden Betrags ist. Sie können dies in den Einstellungen anpassen. Die Gebühr wäre %1$s, was mehr als %2$s%% des zu empfangenden Betrags ist. Diese Einzahlung verfällt am %3$s. - Ihre Kanäle werden gerade initialisiert und konnten die Zahlung nicht empfangen. Versuchen Sie es später noch mal. + Der Zahlungsbetrag ist zu niedrig. + Bei der Überweisung ist ein Fehler aufgetreten. Bitte versuchen Sie es später noch einmal. Bitte öffnen Sie Phoenix Einige Ihrer Kanäle wurden möglicherweise geschlossen. @@ -223,7 +224,6 @@ Automatisches Kanal-Management ist deaktiviert. Die Gebühr wäre %1$s, aber Ihr Gebührenlimit beträgt %2$s. Die Gebühr wäre %1$s, was mehr als %2$s%% des zu empfangenden Betrags ist. - Ihre Kanäle werden gerade initialisiert und konnten die Zahlung nicht empfangen. Tippen, um zu bestätigen. Details ansehen @@ -245,6 +245,7 @@ + Liquidität Miner-Gebühren Gebühren, die an die Miner des Bitcoin-Netzwerks für die Verarbeitung der On-Chain-Transaktion gezahlt werden. Service-Gebühr @@ -253,8 +254,9 @@ Gebühren für die Liquiditätsdienstleistung. Miner-Gebühren Gebühren, die an die Miner des Bitcoin-Netzwerks für die Verarbeitung der On-Chain-Transaktion gezahlt werden. - Dauer - 1 Jahr + Verursacht durch + Diese Liquidität war erforderlich, um eine Zahlung zu erhalten. + Sehen Sie, wie Sie optimieren können @@ -307,7 +309,6 @@ Letzter Versuch (%1$s). Die automatische Gebührenregelung ist deaktiviert. - Channels were still initializing. Dieser Swap wird in einem Tag auslaufen! Dieser Swap wird in %1$s Tagen auslaufen. @@ -391,6 +392,7 @@ Liquiditätsanfrage ist fehlgeschlagen Kanäle sind nicht verfügbar. Versuchen Sie es später erneut. + Der angeforderte Betrag ist ungültig. diff --git a/phoenix-android/src/main/res/values-de/strings.xml b/phoenix-android/src/main/res/values-de/strings.xml index 55efb98da..7ffa72092 100644 --- a/phoenix-android/src/main/res/values-de/strings.xml +++ b/phoenix-android/src/main/res/values-de/strings.xml @@ -337,7 +337,8 @@ Nachricht Nachricht entschlüsseln.. - Desc. + Beschreibung + Hinweis Gesendet an Bitcoin-Miner Gebühren @@ -349,7 +350,8 @@ Kanal-Schließung Migration von Legacy-App Beschleunigte Transaktion - +%1$s eingehende Liquidität + Manuelle Liquidität (+%1$s) + Automatisierte Liquidität Swap-out an %1$s On-Chain Einzahlung @@ -382,7 +384,6 @@ - #%1$s: Geforderter Betrag - Unterschrift Schließungs-Typ Einvernehmlich diff --git a/phoenix-android/src/main/res/values-es/important_strings.xml b/phoenix-android/src/main/res/values-es/important_strings.xml index b77e6643a..3dbd4a79c 100644 --- a/phoenix-android/src/main/res/values-es/important_strings.xml +++ b/phoenix-android/src/main/res/values-es/important_strings.xml @@ -44,7 +44,8 @@ La tasa era %1$s, pero tu tarifa máxima estaba fijada en %2$s. Este depósito expirará el %3$s. La tasa fue de %1$s, que es más del %2$s%% del importe recibido. Puedes modificarlo en la configuración. La tasa fue de %1$s, que es más del %2$s%% del importe recibido. Este depósito expirará el %3$s. - Sus canales se estaban inicializando y no pudieron aceptar ese pago. Vuelva a intentarlo más tarde. + El importe del pago es demasiado bajo. + Se ha producido un error durante la financiación. Vuelva a intentarlo más tarde. Por favor, inicie Phoenix Es posible que algunos de sus canales hayan cerrado. @@ -227,7 +228,6 @@ La gestión automática de canales está desactivada. La tasa era de %1$s, pero su tasa máxima estaba fijada en %2$s. La tasa ascendía a %1$s, lo que supone más del %2$s%% del importe. - Sus canales aún se están inicializando y no han podido recibir ese pago. Puntee para configurar. Ver detalles @@ -249,6 +249,7 @@ + Liquidez Tasas mineras Tasas pagadas a los mineros de la red Bitcoin para procesar la transacción en la cadena. Tasas de servicio @@ -257,8 +258,9 @@ Tasas pagadas por el servicio de liquidez. Tasas mineras Tasa pagada a los mineros de la red Bitcoin para procesar la transacción en cadena. - Duración - 1 año + Causado por + Esta liquidez era necesaria para recibir un pago. + Vea cómo optimizar @@ -308,7 +310,6 @@ La política de tasas automatizadas está desactivada. Los fondos no serán intercambiados. Permanecerán en esta cartera. Último intento (%1$s). La política de tasas automatizadas está desactivada. - Sus canales aún se estaban inicializando. No hay intercambios en curso. Este swap caducará en un día. @@ -394,6 +395,7 @@ Solicitud de liquidez fallida Los canales no están disponibles. Vuelva a intentarlo más tarde. + El importe solicitado no es válido. diff --git a/phoenix-android/src/main/res/values-fr/important_strings.xml b/phoenix-android/src/main/res/values-fr/important_strings.xml index 52c827bbe..121c69fc0 100644 --- a/phoenix-android/src/main/res/values-fr/important_strings.xml +++ b/phoenix-android/src/main/res/values-fr/important_strings.xml @@ -44,7 +44,8 @@ Les frais étaient de %1$s, mais votre max est de %2$s. Le dépôt expirera le %3$s. Les frais étaient de %1$s et dépassent %2$s%% du montant. Cette configuration peut être changée. Les frais étaient de %1$s et dépassent %2$s%% du montant. Le dépôt expirera le %3$s. - Vos canaux de paiement étaient en initialisation, et n\'ont pu accepter ce paiement. + Le montant du paiement est trop faible. + Une erreur est survenue lors de l\'ajout de fonds. Veuillez réessayer plus tard. Veuillez démarrer Phoenix Certains de vos canaux pourraient avoir fermé. @@ -227,7 +228,6 @@ La gestion des canaux automatisée est désactivée. Les frais étaient de %1$s, mais votre max est de %2$s. Les frais de %1$s représentaient plus de %2$s%% du montant. - Vous canaux étaient en initialisation et n\'ont pu recevoir ce paiement. Cliquez pour configurer. Voir les détails @@ -249,16 +249,18 @@ - Frais de\nminage + Liquidité + Frais de minage Frais payés aux mineurs du réseau Bitcoin pour traiter la transaction on-chain. - Frais de\nservice + Frais de service Frais payés pour la création d\'un nouveau canal de paiement. Cette action n\'est pas toujours nécessaire. Frais de\nservice Frais payés pour le service de liquidité. Frais de\nminage Frais payés aux mineurs du réseau Bitcoin pour traiter la transaction sur la chaîne. - Durée - 1 an + Causé par + Cette liquidité était nécessaire pour recevoir un paiement. + Voir comment optimiser @@ -308,7 +310,6 @@ La gestion des canaux automatisée est désactivée. Les fonds ne seront pas basculés sur Lightning et vont rester sur ce wallet. Dernière tentative (%1$s). La gestion des canaux automatisée était désactivée. - Les canaux étaient encore en cours d\'initialisation. Il n\'y a pas de fonds en attente de swap. Ce swap va expirer dans moins d\'une journée! @@ -394,6 +395,7 @@ La demande de liquidité a échoué Vos canaux ne sont pas disponibles. Réessayez plus tard. + Le montant demandé n\'est pas valide. diff --git a/phoenix-android/src/main/res/values-fr/strings.xml b/phoenix-android/src/main/res/values-fr/strings.xml index 9856c974b..c84f0e7be 100644 --- a/phoenix-android/src/main/res/values-fr/strings.xml +++ b/phoenix-android/src/main/res/values-fr/strings.xml @@ -327,7 +327,8 @@ Message Déchiffrement du message… - Desc. + Description + Note Envoyé à Mineurs Bitcoin Frais @@ -347,7 +348,8 @@ Fermeture de canal Migration depuis l\'ancienne appli Accélération de transactions - +%1$s de liquidité entrante + Liquidité manuelle (+%1$s) + Liquidité automatique Swap-out vers %1$s Dépôt on-chain @@ -380,7 +382,6 @@ - #%1$s: Liquidité demandée - Signature Type de clôture Mutuelle diff --git a/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml b/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml index 75c5c5ed4..57163ca31 100644 --- a/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml +++ b/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml @@ -44,7 +44,8 @@ A taxa foi de %1$s, mas sua taxa máxima foi definida como %2$s. Esse depósito expirará em %3$s. A taxa foi de %1$s, que é mais do que %2$s%% do valor recebido. Você pode ajustar isso nas configurações. A taxa foi de %1$s, que é mais do que %2$s%% do valor recebido. Esse depósito expirará em %3$s. - Seus canais estavam sendo inicializados e não puderam aceitar esse pagamento. Tente novamente mais tarde. + O valor do pagamento é muito baixo. + Ocorreu um erro durante o financiamento. Tente novamente mais tarde. Por favor, inicie o Phoenix Alguns de seus canais podem ter sido fechados. @@ -226,7 +227,6 @@ O gerenciamento automatizado de canais está desativado. A taxa foi de %1$s, mas sua taxa máxima foi definida como %2$s. A taxa foi de %1$s, que é mais do que %2$s%% do valor. - Seus canais ainda estão sendo inicializados e não puderam receber esse pagamento. Toque para configurar. Ver detalhes @@ -248,6 +248,7 @@ + Liquidez Taxas do minerador Taxas pagas aos mineradores da rede Bitcoin para processar a transação na cadeia. Taxas de serviço @@ -256,8 +257,9 @@ Taxas pagas pelo serviço de liquidez. Taxas do minerador Taxas pagas aos mineradores da rede Bitcoin para processar a transação na cadeia. - Duração - 1 ano + Causado por + Essa liquidez era necessária para receber um pagamento. + Veja como otimizar @@ -303,7 +305,6 @@ A política de taxas automatizadas está desativada. Os fundos não serão trocados. Eles permanecerão nessa carteira. Última tentativa (%1$s). A política de taxas automatizadas está desativada. - Seus canais ainda estavam sendo inicializados. Não há swaps em andamento. Essa troca expirará em um dia! @@ -389,6 +390,7 @@ A solicitação de liquidez falhou Os canais não estão disponíveis. Tente novamente mais tarde. + O valor solicitado é inválido. diff --git a/phoenix-android/src/main/res/values-pt-rBR/strings.xml b/phoenix-android/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 000000000..310c62f0d --- /dev/null +++ b/phoenix-android/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,725 @@ + + + + + + Observador de canais + Exibido quando você precisa iniciar o Phoenix. + + Pagamento pendente + Informa quando o Phoenix deve ser iniciado para efetuar um pagamento. + + Pagamento rejeitado + Exibido quando o Phoenix não consegue receber um pagamento devido a um problema de liquidez. + + Pagamento recebido + Exibido quando você recebe um pagamento enquanto o aplicativo está em segundo plano. + + Execução em segundo plano + Informa quando o Phoenix está sendo executado em segundo plano. + + Tempo limite de troca + Informa quando uma troca expirará. + + + + Criando sua carteira… + Erro ao criar carteira + + Restaurar minha carteira + Próximo + Restaurando sua carteira… + + + + Desbloquear para continuar + + + + Vinculando o serviço… + Preparando a carteira… + Verificando carteira herdada… + Desbloqueando… + Acesso concedido… + Falha no desbloqueio + Erro do Android Keystore + Desbloquear com seed + + Digite suas 12 palavras de recuperação para desbloquear a carteira.\n\nAs palavras devem ser digitadas na ordem correta e separadas por um único espaço. + Digite a palavra #%1$d + Não insira mais de 12 palavras! + Insira a semente completa para continuar. + Esta semente é inválida. Verifique se as palavras estão corretas e em ordem. + Desbloquear carteira + Verificando a semente… + Ocorreu um erro. Tente novamente. + Esta semente não corresponde à carteira. + Correspondências iniciais.\nGravando no disco… + Iniciando o Phoenix… + + Iniciando… + Não foi possível iniciar o aplicativo. + + + + Aplicativo legado + Reinicie o aplicativo legado + + + + - + + + Aguardando confirmação + Pagamento pendente + Pagamento confirmado + Pagamento concluído + Erro ao processar o pagamento + + + + Fatura ou código QR do endereço + + Bitcoin + Este código QR é um endereço Bitcoin clássico.\n\nQuase qualquer serviço ou carteira Bitcoin pode lê-lo, mas os pagamentos levarão mais tempo para chegar. + Endereço + Não foi possível gerar um endereço Bitcoin + Endereço Bitcoin + Compartilhe este endereço Bitcoin com… + + Lightning + Este código QR é uma fatura Lightning.\n\nOs pagamentos Lightning são muito rápidos e geralmente mais baratos, mas podem ainda não ser suportados por algumas carteiras e serviços.\n\nNesse caso, deslize para a esquerda para obter um endereço Bitcoin normal. + Gerando… + Valor + Desc + Não foi possível gerar a fatura + fatura relâmpago + Compartilhe este endereço do Lightning com… + Verificar + + Editar + Editar fatura + Valor (opcional) + Valor a ser recebido + Descrição (opcional) + Insira uma descrição para esta fatura + Criar fatura + + + + Saldo: + O valor é muito alto. + O valor não pode ser negativo. + Este valor é inválido. + Este valor excede o saldo. + Não é possível pagar mais de %1$s. + Este valor é menor que o valor solicitado de %1$s. + Enviar para + Descrição + Comissão + N/A + Carregando comissão… + Fatura sem valor + A fatura deste pagamento não solicita um valor específico. Nós maliciosos podem tirar vantagem disso durante a finalização da compra.\n\nPara maior segurança, peça ao destinatário para especificar um valor ao gerar a fatura. + Aguardando canais… + Pagar + + Pagar em cadeia + As transações on-chain são normalmente mais lentas e mais adequadas para fazer grandes pagamentos. + Pague com Lightning + Os pagamentos relâmpago são rápidos e mais adequados para valores baixos. + + Endereço + Taxa de comissão + Obtendo a taxa de comissão atual… + + Insira uma taxa de comissão válida. + Total excede o saldo + Erro ao processar o pagamento + + Preparar transação + Preparando transação… + Executando emenda… + Taxa de mineração + Usa uma taxa de comissão efetiva de %1$s sat/vbyte. + Total + + + + Entrada manual + Colar dados da área de transferência + Toque para conceder permissão de câmera + Permissão de câmera negada + + Obtendo dados do serviço… + + Entrada manual + Insira uma fatura Lightning, LNURL ou endereço Lightning para o qual deseja enviar dinheiro. + Fatura ou endereço Lightning + + + + + ≈ %1$s + Taxa de câmbio BTC não disponível + + agora + N/A + Carregando dados… + Carregando preferências… + Copiado para a área de transferência + + Abrir link em um navegador + Abrir transação em um explorador + Este campo não pode ficar vazio + Insira um valor válido + Insira um número válido + Este campo deve conter um número inteiro + + Retornar + Próximo + Copiar + OK + Salvar + Confirmar + Cancelar + Fechar + + + + Esvaziar minha carteira + Carregando… + Verificando saldo… + Saldo: %1$s (≈ %2$s) + A carteira não possui canais que possam ser fechados. + Esvaziar minha carteira + Este endereço usa um blockchain diferente + Este endereço usa funções não suportadas + Este endereço não é suportado + %1$d canal(is) fechado(s). Você pode encontrar os detalhes na tela inicial. + + Não há canais que possam ser fechados. + + + + Canais de pagamento + Importar canais + Saldo + O saldo é a quantidade total de canais ativos. Isso é o que você pode gastar no Lightning. + Liquidez de entrada + Liquidez de entrada é o que os canais podem receber via Lightning sem precisar entrar na rede e pagar taxas. + Carregando dados do canal… + Você ainda não tem canais.\n\nUm novo canal pago será criado automaticamente quando necessário. + + + + Detalhes do canal + Não há canal ativo para esse identificador. + Identificador do canal + Estado + Saldo + + Compromissos ativos + Compromissos inativos + Transação de financiamento: + Saldo: + Capacidade: + Iniciado por: + + Exibir dados brutos + Compartilhar + Dados do canal + Compartilhar dados do canal + + + + Importar dados brutos do canal + Esta tela é uma ferramenta de depuração que pode ser usada para importar manualmente dados criptografados do canal.\n\nUse com cuidado. + Blob de dados + Importar + Importando dados… + Importação bem-sucedida + Você deve reiniciar o Phoenix agora. + Erro de importação + O formato dos dados está incorreto. É esperado um blob hexadecimal criptografado. + Não foi possível descriptografar os dados com esta carteira. + A versão %1$d não é suportada + + + + Carregando… + +%1$s + + Perguntas frequentes + Use os botões Receber e Enviar na parte inferior desta tela para começar. + Mostrar todos os pagamentos… + Atraso de Eletro + Certificado Electro + Conectando… + Tor + + + + Notificações + Mensagens importantes + Atividade recente + Nenhuma notificação ainda + + + + Frase de recuperação + Desbloqueando semente… + Não foi possível desbloquear a semente + Semente BIP39 com caminho de derivação BIP84 padrão + Carregando preferências… + Confirmação de backup + + + + Logs de aplicativos + Exportando registros… + Erro: não foi possível exportar logs + Ver registros… + Ver registros com… + Compartilhar registros… + Logs do aplicativo Phoenix + Compartilhe registros do Phoenix… + + + + Carregando detalhes de pagamento… + Detalhes do pagamento não encontrados + + COMPLETO %1$s + ENVIADO %1$s + Pendente… + FALHA\nNenhum dinheiro foi enviado. + + Ainda não recebido. + Aguardando a abertura do canal + RECEBIDO %1$s + + Aguardando confirmações + Obtendo status… + 0 confirmações + Toque para acelerar + %1$d confirmações + %1$d/%2$d confirmação(ões) + Confirmado em string + + Serviço por + Mensagem + Link + Abrir link + Mensagem + Descriptografando mensagem… + + Descrição + Nota + Enviado para + Mineradores de Bitcoin + Taxas + Erro + Sem descrição + Este pagamento foi feito devido a um conflito em um canal. + + Pagamento em cadeia + Encerrando canal + Migração de aplicativo legado + Impulsionar transações + Liquidez manual (+%1$s) + Liquidez automatizada + Trocar para %1$s + Depósito na rede + + %1$s + de %1$s + + Adicione uma descrição personalizada para este pagamento + Descrição + Anexar nota + Editar nota + Detalhes técnicos + + + + Detalhes técnicos + Tipo de pagamento + Pagamento pelo encerramento do canal + Pagamento pela emenda de saída + Acelere as transações na rede + Pagamento relâmpago (padrão) + Recebimento de pagamento relâmpago (padrão) + Depósito de Bitcoin para troca + Trocar para endereço Bitcoin + Endereço de depósito + Endereço Bitcoin + + Canal emendado + Transações + - #%1$s: + + Tipo de fechamento + Muto + Local + Remoto + Revogado + Outro + + Chave pública de destino + Descrição da fatura + Hash de pagamento + Fatura + Pré-imagem + + Status do pagamento + Sucesso + Confirmando + Pendente + Erro + + Partes do pagamento (%1$d) + Parte + Lúpulos + + Recebido via + Splice (adicionando ao canal existente) + Novo canal (criado automaticamente) + Pagamento relâmpago + Identificador do canal + Transação + + Criado em + Concluído em + Decorrido + %1$s ms + + Valor solicitado + Valor enviado (comissões incluídas) + Valor recebido + ≈ %1$s (agora) + ≈ %1$s (então) + + + + Acesso ao aplicativo + + Não é possível ativar o bloqueio de tela + Não há hardware de autenticação adequado neste dispositivo. + O hardware biométrico não está disponível. Tente novamente mais tarde. + Primeiro registre um PIN, esquema ou impressão digital no Android. + O hardware não é seguro. É necessária uma atualização de segurança do Android. + Não compatível com esta versão do Android. + Muitas tentativas, tente novamente mais tarde. + Erro não tratado do fornecedor de hardware + Tempo limite da tentativa de autenticação expirou + A autenticação foi cancelada + Esta versão do Android não é compatível. + Código de erro não tratado: %1$d + + Bloqueio de tela do sistema + Bloqueie o acesso ao aplicativo com bloqueio de tela do Android ou impressão digital. + + + + Modo legado + Toque aqui para obter mais informações + Como funciona? + Phoenix autêntica com uma chave exclusiva para %1$s. Essa chave exclusiva se torna a senha da sua conta. + Privacidade + O serviço não terá acesso à sua carteira. Eles não poderão ver seu saldo, seus pagamentos ou suas senhas. + Modo legado + Phoenix usa um esquema não padrão neste serviço para ser compatível com versões mais antigas do aplicativo. A conta associada não pode ser transferida para outras carteiras. + Alterar esquema + Login + Tente novamente + Efetuando login em\n%1$s. + Autenticação bem-sucedida. + Erro de autenticação: + Erro de rede. Verifique sua conexão com a Internet e tente novamente. + Ocorreu um erro desconhecido. Tente novamente + + + + Padrão + Usa um esquema padrão que está em conformidade com as especificações LNURL. Esta é a opção recomendada para novas carteiras e usada pelo aplicativo Phoenix iOS. + Android Legacy + Use um esquema legado para se conectar a contas criadas com o antigo aplicativo Phoenix para Android. + + + + Resgatar + Solicitando fundos… + O valor deve ser de pelo menos %1$s. + O valor não pode exceder %1$s. + Erro de retirada: + + + + O serviço %1$s respondeu com um erro. Entre em contato com o suporte técnico, se necessário.\n\nDetalhes da mensagem de serviço: \"%2$s\" + O serviço %1$s retornou um erro HTTP (%2$s).\n\nEntre em contato com o suporte técnico, se necessário. + O serviço %1$s retornou uma mensagem malformada. + Não foi possível conectar ao serviço %1$s. + + + + Atendido por + Descrição + Anexar uma mensagem + Minha mensagem + Você pode anexar uma mensagem ao pagamento. Esta mensagem será enviada ao destinatário. + Pagar + Solicitando fatura… + + O valor deve ser de pelo menos %1$s. + O valor deve ser no máximo %1$s. + + Erro ao processar o pagamento. + A fatura retornada por %1$s não usa a mesma cadeia de carteira. + A fatura devolvida por %1$s já foi paga. + A fatura retornada por %1$s tem um valor incorreto. + A fatura retornada por %1$s está malformada + + + + Opções de exibição + Unidade Bitcoin + Satoshi (sat) + 1 sat equivale a 0,00000001 BTC + Bit (bit) + 1 bit equivale a 0,000001 BTC + Milibitcoin (mBTC) + 1 mBTC equivale a 0,001 BTC + Bitcoin (BTC) + Moeda fiduciária + Tema do aplicativo + Tema escuro + Tema claro + Igual ao sistema + Idioma do aplicativo + + + + Servidor Electrum + Para proteger seus canais de pagamento, a Phoenix monitora o blockchain do Bitcoin por meio de servidores Electrum.\n\nPor padrão, são usados servidores aleatórios. Você também pode configurar o Phoenix para se conectar apenas ao seu próprio servidor. + Altura do bloco + + Desconectado da Electrum + Desconectado de %1$s + Conectando-se a %1$s (aleatório) + Conectando-se a %1$s + Conectando-se a %1$s + + Você está usando um servidor personalizado + Este servidor forneceu um certificado desconhecido. A conexão foi recusada. + + Use um servidor personalizado + Endereço do servidor (host:porta) + Este endereço é inválido. + Conectar + Verificando certificado… + Erro de conexão:\n%1$s + Este endereço não pode ser resolvido. + Certificado não confiável + Impressão digital SHA1 + Impressão digital SHA256 + Emissor + Assunto + Válido até + Copiar certificado + Certificado de confiança + + + + Tor + Verificando preferências… + Tor está ativado + Tor está desabilitado + O proxy Tor ainda não foi iniciado. + O proxy Tor está desativado.\nAguarde e verifique sua conexão com a Internet. + Iniciando o proxy Tor… + Tor está online + + + + Status da conexão + O aplicativo não funcionará corretamente até que todas as conexões tenham sido estabelecidas. + Seu dispositivo não possui conexão com a Internet. O app não funcionará corretamente.\n\nVerifique as configurações do seu dispositivo. + Internet + Electrum + Tor + Par + Gerenciar conexão para %1$s + Conectando… + Conectado + O servidor Electrum registra um atraso (bloco $1$d) + Desconectado + Certificado com defeito + + + + Informações sobre Phoenix + Versão Phoenix: %1$s + Você tem dúvidas? Verifique as perguntas frequentes + Suporte + Privacidade + Condições + + + + Opções de pagamento + + Pagamentos recebidos + LNURL + + Descrição da fatura + Nenhuma descrição definida… + Descrição padrão + As faturas usarão esta descrição por padrão. Você pode cancelar caso a caso. + Descrição da fatura + + Expiração da fatura + Expiração da fatura + As faturas criadas expiram após esse período. O valor padrão é 1 semana. + 1 hora + 1 dia + 1 semana (padrão) + 2 semanas + 3 semanas + %1$s segundos + + Esquema de autenticação LNURL + + + + Peso argentino (taxa oficial) + Peso argentino + Peso cubano (taxa oficial) + Peso cubano + Libra libanesa (taxa oficial) + Libra libanesa + + + + Pagamentos locais (%1$d) + Exportar + Hoje + Ontem + Esta semana + Semana passada + Exportar pagamentos + Exporte pagamentos locais bem-sucedidos em formato CSV (valores separados por vírgula). + Data de início + Data de término + Incluir origem/destino + Incluir descrição + Exportar + Pagamentos ainda não efetuados + Escolha uma data de início/término válida + Exportando pagamento #%1$d + %1$d pagamentos exportados com sucesso + Phoenix - pagamentos de %1$s a %2$s + Compartilhe pagamentos Phoenix… + Compartilhar arquivo + Erro de exportação. + Nenhum pagamento encontrado. + + + + (par) + + + + Informações da carteira + Descritor + Chave pública mestra + (Caminho: %1$s) + Pronto para trocar + Aguardando %1$d confirmações + +%1$d mais… + Saldo confirmado + Saldo não confirmado + +%1$s recebidos + Carregando dados da carteira… + + Lightning + Identificador do nó + Identificador de nó legado + + Carteira final + + + + Gerenciamento de canais + Obtendo taxa de comissão… + Minhas configurações de taxas + Gerenciamento avançado de canais + Obtendo política… + + + + Taxa de comissão + %1$s sat/vbyte + + Preparar pagamento + Calculando comissões… + Você pagará %1$s aos mineradores de Bitcoin + Executar pagamento + Executando pagamento… + Pagamento concluído + Erro ao executar o pagamento + Não é possível continuar + + + + Status do pool de memória desconhecido + O Phoenix não conseguiu obter o estado atual do pool de memória, portanto não pode calcular a velocidade da transação.\n\nVerifique o pool de memória manualmente em um navegador e use um valor apropriado . + ≈ Próximo bloco + ≈ 30 minutos + ≈ 1 hora + Comissão baixa + + + + Você não tem canais + Abortado pelo par [%1$s] + Não é possível criar um novo commit + O canal está desconectado + Erro de financiamento [%1$s] + Fundos insuficientes + Não é possível iniciar a sessão de transação com peer + Erro de sessão de transação interativa [%1$s] + O script de chave pública de emenda é inválido + Um pagamento de emenda já está em andamento + + + + Redefinir carteira + Excluir todos os dados da carteira neste dispositivo + Isso redefinirá o aplicativo, como se você tivesse acabado de instalá-lo. + Analisar + + Confirmar redefinição + A carteira será completamente excluída neste dispositivo. + Isso redefinirá o aplicativo. Iremos direcioná-lo para a tela de introdução, onde solicitaremos que você crie ou restaure uma carteira. + Não perca seus fundos:\n%1$s (≈ %2$s). + Você é responsável por salvar sua frase de recuperação. + Excluir carteira + + A carteira foi redefinida com sucesso + Redefinir erro + + diff --git a/phoenix-android/src/main/res/values-sk/important_strings.xml b/phoenix-android/src/main/res/values-sk/important_strings.xml index 0b61efaa3..eb7505d1a 100644 --- a/phoenix-android/src/main/res/values-sk/important_strings.xml +++ b/phoenix-android/src/main/res/values-sk/important_strings.xml @@ -44,7 +44,8 @@ Poplatok bol %1$s, ale váš maximálny poplatok bol nastavený na %2$s. Platnosť tohto vkladu vyprší %3$s. Poplatok bol %1$s, čo je viac než %2$s%% z prijatej sumy. Kliknite pre podrobnosti. Poplatok bol %1$s, čo je viac než %2$s%% z prijatej sumy. Platnosť tohto vkladu vyprší %3$s. - Vaše kanály sa inicializovali a nemohli prijať túto platbu. Skúste to neskôr. + Výška platby je príliš nízka. + Počas financovania došlo k chybe. Skúste to prosím neskôr. Spustite prosím Phoenix Niektorý z vašich kanálov mohol byť uzavretý. @@ -226,7 +227,6 @@ Automatická správa kanálov je zakázaná. Poplatok bol %1$s, ale váš maximálny poplatok bol nastavený na %2$s. Poplatok bol %1$s, čo je viac než %2$s%% sumy. - Vaše kanály sa stále inicializujú a nemohli prijať túto platbu. Kliknutím na položku vykonajte konfiguráciu. Zobraziť podrobnosti @@ -248,6 +248,7 @@ + Likvidity Poplatky ťažiarom Poplatky platené ťažiarom Bitcoinovej siete za spracovanie on-chain transakcie. Poplatky za službu @@ -256,8 +257,9 @@ Poplatky za službu likvidity. Poplatky ťažiarom Poplatky platené ťažiarom Bitcoinovej siete za spracovanie on-chain transakcie. - Trvanie - 1 rok + Spôsobené + Táto likvidita bola potrebná na získanie platby. + Pozrite sa, ako optimalizovať @@ -307,7 +309,6 @@ Automatizovaná poplatková politika je vypnutá. Prostriedky nebudú vymieňané. Zostanú na tejto peňaženke. Posledný pokus (%1$s). Automatizovaná poplatková politika je vypnutá. - Vaše kanály boli inicializované. Neprebiehajú žiadne výmeny. Platnosť tejto výmeny vyprší za deň! @@ -394,6 +395,7 @@ Žiadosť o likviditu zlyhala Kanály nie sú k dispozícii. Skúste to neskôr. + Požadovaná suma je neplatná. diff --git a/phoenix-android/src/main/res/values-sk/strings.xml b/phoenix-android/src/main/res/values-sk/strings.xml index 4c65ccc00..fc10da61e 100644 --- a/phoenix-android/src/main/res/values-sk/strings.xml +++ b/phoenix-android/src/main/res/values-sk/strings.xml @@ -358,6 +358,7 @@ Dešifrovanie správy… Popis + Poznámka Poslané Bitcoinoví ťažiari Poplatky @@ -369,7 +370,8 @@ Zatváranie kanála Migrácia zo staršej aplikácie Urýchliť transakciu - +%1$s prichádzajúca likvidita + Manuálna likvidita (+%1$s) + Automatizovaná likvidita Swap-out na %1$s On-chain vklad @@ -404,7 +406,6 @@ - #%1$s: Požadovaná suma - Podpis Typ uzavretia Vzájomné diff --git a/phoenix-android/src/main/res/values-sw/important_strings.xml b/phoenix-android/src/main/res/values-sw/important_strings.xml index 16b45bbdc..3c6764456 100644 --- a/phoenix-android/src/main/res/values-sw/important_strings.xml +++ b/phoenix-android/src/main/res/values-sw/important_strings.xml @@ -48,7 +48,8 @@ Ada ilikuwa %1$s, lakini ada yako ya juu ilikuwa imewekwa kwa %2$s. Amana hii itaisha muda wake ifikapo %3$s. Ada ilikuwa %1$s ambayo ni zaidi ya %2$s%% ya kiasi kilichopokelewa. Bonyeza kwa maelezo zaidi. Ada ilikuwa %1$s ambayo ni zaidi ya %2$s%% ya kiasi kilichopokelewa. Amana hii itaisha muda wake ifikapo %3$s. - Chaneli zako zilikuwa zinaanzishwa na hazikuweza kukubali malipo hayo. Jaribu tena baadaye. + Kiasi cha malipo ni kidogo sana. + Hitilafu ilitokea wakati wa ufadhili. Tafadhali jaribu tena baadaye. Tafadhali anzisha Phoenix Baadhi ya chaneli zako zinaweza kuwa zimefungwa. @@ -229,7 +230,6 @@ Usimamizi wa njia otomatiki umelemazwa. Ada ilikuwa %1$s, lakini ada yako ya juu ilikuwa imewekwa kwa %2$s. Ada ilikuwa %1$s ambayo ni zaidi ya %2$s%% ya kiasi hicho. - Njia zako bado zinaanzishwa na haziwezi kupokea malipo hayo. Gusa kusanidi. Angalia maelezo @@ -251,6 +251,7 @@ + Ukwasi Ada za wachimba madini Ada zilizolipwa kwa wachimba madini wa mtandao wa Bitcoin kushughulikia muamala wa mtandaoni. Ada za huduma @@ -259,8 +260,9 @@ Ada zilizolipwa kwa huduma ya ukwasi. Ada za wachimba madini Ada zilizolipwa kwa wachimba madini wa mtandao wa Bitcoin kushughulikia muamala wa mtandaoni. - Muda - 1 mwaka + Imesababishwa na + Ukwasi huu ulihitajika ili kupokea malipo. + Tazama jinsi ya kuboresha @@ -313,7 +315,6 @@ Jaribio la kubadilisha lilishindikana %1$s Usimamizi wa njia ulikuwa umelemazwa. - Njia zilikuwa bado zinaanzishwa. Swap hii itaisha muda wake ndani ya siku moja! Swap hii itaisha muda wake ndani ya siku %1$s. @@ -397,6 +398,7 @@ Ombi la ukwasi limeshindwa Njia hazipatikani. Jaribu tena baadaye. + Kiasi kilichoombwa ni batili. diff --git a/phoenix-android/src/main/res/values-sw/strings.xml b/phoenix-android/src/main/res/values-sw/strings.xml index 8fdbcbc72..7aa25759e 100644 --- a/phoenix-android/src/main/res/values-sw/strings.xml +++ b/phoenix-android/src/main/res/values-sw/strings.xml @@ -388,7 +388,8 @@ Kufunga channel Uhamaji kutoka programu ya zamani Kuweka vipaumbele vya muamala - +%1$s fedha za ndani + Ukwasi wa mwongozo (+%1$s) + Ukwasi wa kiotomatiki Kubadilisha kwa %1$s Depo ya on-chain @@ -423,7 +424,6 @@ - #%1$s: Kiasi kilichoombwa - Sahihi Aina ya kufunga Kushirikiana diff --git a/phoenix-android/src/main/res/values-vi/important_strings.xml b/phoenix-android/src/main/res/values-vi/important_strings.xml index a8a7d5224..0395967c5 100644 --- a/phoenix-android/src/main/res/values-vi/important_strings.xml +++ b/phoenix-android/src/main/res/values-vi/important_strings.xml @@ -51,7 +51,8 @@ Khoản phí là %1$s, nhưng phí tối đa của bạn được đặt là %2$s. Khoản tiền cọc này sẽ hết hạn vào %3$s. Khoản phí là %1$s và cao hơn %2$s%% so với khoản nhận được. Nhấn để biết thêm chi tiết. Khoản phí là %1$s và cao hơn %2$s%% so với khoản tiền sẽ nhận được. Khoản tiền này sẽ hết hạn vào %3$s. - Kênh của bạn đang được khởi tạo và không thể nhận khoản thanh toán này. Vui lòng thử lại sau. + Số tiền thanh toán quá thấp. + Đã xảy ra lỗi trong quá trình cấp vốn. Vui lòng thử lại sau. Xin hãy khởi động Phoenix. Một vài kênh của bạn có thể đã đóng. @@ -233,7 +234,6 @@ Chức năng quản lý kênh tự động bị tắt. Phí là %1$s, tuy nhiên mức phí tối đa của bạn được đặt là %2$s. Phí là %1$s và cao hơn %2$s%% so với số tiền. - Các kênh của bạn vẫn đang được khởi tạo và không thể nhận khoản thanh toán này. Nhấn để định cấu hình. Xem chi tiết @@ -255,6 +255,7 @@ + Thanh khoản Các khoản phí đào Phí thanh toán cho mạng lưới thợ đào Bitcoin để xử lý giao dịch on-chain. Phí dịch vụ @@ -263,8 +264,9 @@ Phí dịch vụ thanh khoản. Phí đào Phí thanh toán cho mạng lưới thợ đào Bitcoin để xử lý giao dịch on-chain. - Thời hạn - 1 năm + Do + Cần có thanh khoản này để nhận được khoản thanh toán. + Xem cách tối ưu hóa @@ -317,7 +319,6 @@ Một giao dịch swap không thành công %1$s Chức năng quản lý kênh tự động đã bị tắt. - Các kênh vẫn đang được khởi tạo. Giao dịch swap này sẽ hết hạn sau 1 ngày nữa! Giao dịch swap này sẽ hết hạn sau %1$s ngày nữa. @@ -401,6 +402,7 @@ Yêu cầu thanh khoản không thành công Các kênh đang bận. Vui lòng thử lại sau. + Số tiền được yêu cầu không hợp lệ. diff --git a/phoenix-android/src/main/res/values-vi/strings.xml b/phoenix-android/src/main/res/values-vi/strings.xml index cbd184fc9..66f2eb9b8 100644 --- a/phoenix-android/src/main/res/values-vi/strings.xml +++ b/phoenix-android/src/main/res/values-vi/strings.xml @@ -350,7 +350,8 @@ Đang đóng kênh Di chuyển từ ứng dụng cũ Các giao dịch tăng đột biến - +%1$s thanh khoản đầu vào + Thanh khoản thủ công (+%1$s) + Thanh khoản tự động Swap-out thành %1$s Tiền cọc on-chain @@ -383,7 +384,6 @@ - #%1$s: Số tiền yêu cầu - Chữ ký Hình thức đóng Đồng thuận diff --git a/phoenix-android/src/main/res/values/important_strings.xml b/phoenix-android/src/main/res/values/important_strings.xml index a67f3efc9..800d87a01 100644 --- a/phoenix-android/src/main/res/values/important_strings.xml +++ b/phoenix-android/src/main/res/values/important_strings.xml @@ -48,7 +48,8 @@ The fee was %1$s, but your max fee was set to %2$s. This deposit will expire by %3$s. The fee was %1$s which is more than %2$s%% of the amount received. Tap for details. The fee was %1$s which is more than %2$s%% of the amount received. This deposit will expire by %3$s. - Your channels were initializing and could not accept that payment. Try again later. + Payment amount is too low. + An error occurred during funding. Please try again later. Please start Phoenix Some of your channels may have closed. @@ -229,7 +230,6 @@ Automated channel management is disabled. The fee was %1$s, but your max fee was set to %2$s. The fee was %1$s which is more than %2$s%% of the amount. - Your channels are still initializing and could not receive that payment. Tap to configure. View details @@ -255,12 +255,15 @@ Fees paid to the Bitcoin network miners to process the on-chain transaction. Service fees Fees paid for the creation of a new payment channel. This is not always required. + + Liquidity Service fees Fees paid for the liquidity service. Miner fees Fees paid to the Bitcoin network miners to process the on-chain transaction. - Duration - 1 year + Caused by + This liquidity was required to receive a payment. + See how to optimise @@ -313,7 +316,6 @@ A swap attempt failed %1$s Channels management was disabled. - Channels were still initializing. This swap will expire in a day! This swap will expire in %1$s days. @@ -397,6 +399,7 @@ Liquidity request has failed Channels are not available. Try again later. + The requested amount is invalid. diff --git a/phoenix-android/src/main/res/values/strings.xml b/phoenix-android/src/main/res/values/strings.xml index 8b4251ed2..de170940c 100644 --- a/phoenix-android/src/main/res/values/strings.xml +++ b/phoenix-android/src/main/res/values/strings.xml @@ -349,6 +349,7 @@ Could not find payment details LIQUIDITY ADDED %1$s + CHANNEL RESIZED %1$s COMPLETE %1$s SENT %1$s Pending… @@ -378,7 +379,8 @@ Message Decrypting message… - Desc. + Description + Note Sent to Bitcoin miners Fees @@ -390,7 +392,8 @@ Closing channel Migration from legacy app Bump transactions - +%1$s inbound liquidity + Manual liquidity (+%1$s) + Automated liquidity Swap-out to %1$s On-chain deposit @@ -425,7 +428,6 @@ - #%1$s: Amount requested - Signature Closing type Mutual @@ -444,6 +446,7 @@ Offer Bolt12 invoice Metadata + Purchase type Payment status Successful @@ -459,6 +462,7 @@ Splice-in (adding to existing channel) New channel (automatically created) Lightning payment + Fee credit Channel id Transaction @@ -470,6 +474,8 @@ Amount requested Amount sent (fees included) Amount received + Fee credit accrued + Amount added to fee credit ≈ %1$s (now) ≈ %1$s (then) @@ -816,6 +822,8 @@ Invalid splice-out pubkey script A splice payment is already in progress Invalid liquidity-ads request: [%1$s] + Invalid channel parameters: [%1$s] + Unexpected error: [%1$s] diff --git a/phoenix-android/src/test/kotlin/fr/acinq/phoenix/utils/LegacyMigrationHelperTest.kt b/phoenix-android/src/test/kotlin/fr/acinq/phoenix/utils/LegacyMigrationHelperTest.kt index b9824914c..10773f037 100644 --- a/phoenix-android/src/test/kotlin/fr/acinq/phoenix/utils/LegacyMigrationHelperTest.kt +++ b/phoenix-android/src/test/kotlin/fr/acinq/phoenix/utils/LegacyMigrationHelperTest.kt @@ -91,7 +91,7 @@ class LegacyMigrationHelperTest { // transform legacy payments to modern OutgoingPayment objects val newOutgoingPayments = legacyOutgoingPayments.map { LegacyMigrationHelper.modernizeLegacyOutgoingPayment( - chain = Chain.Testnet, + chain = Chain.Testnet3, parentId = it.key, listOfParts = it.value, paymentMeta = paymentMetaRepository.get(it.key.toString()) diff --git a/phoenix-android/src/test/kotlin/fr/acinq/phoenix/utils/LnurlAuthTest.kt b/phoenix-android/src/test/kotlin/fr/acinq/phoenix/utils/LnurlAuthTest.kt index 921f0f007..7e3ca418b 100644 --- a/phoenix-android/src/test/kotlin/fr/acinq/phoenix/utils/LnurlAuthTest.kt +++ b/phoenix-android/src/test/kotlin/fr/acinq/phoenix/utils/LnurlAuthTest.kt @@ -26,7 +26,7 @@ class LnurlAuthTest { val legacyKeyManager = fr.acinq.eclair.crypto.LocalKeyManager(seed, Block.TestnetGenesisBlock().hash()) val kmpKeyManager = LocalKeyManager( seed = seed.toArray().byteVector64(), - chain = Chain.Testnet, + chain = Chain.Testnet3, remoteSwapInExtendedPublicKey = "tpubDDt5vQap1awkyDXx1z1cP7QFKSZHDCCpbU8nSq9jy7X2grTjUVZDePexf6gc6AHtRRzkgfPW87K6EKUVV6t3Hu2hg7YkHkmMeLSfrP85x41" ) @@ -65,7 +65,7 @@ class LnurlAuthTest { val legacyKeyManager = fr.acinq.eclair.crypto.LocalKeyManager(seed, Block.LivenetGenesisBlock().hash()) val kmpKeyManager = LocalKeyManager( seed = seed.toArray().byteVector64(), - chain = Chain.Testnet, + chain = Chain.Testnet3, remoteSwapInExtendedPublicKey = "tpubDDt5vQap1awkyDXx1z1cP7QFKSZHDCCpbU8nSq9jy7X2grTjUVZDePexf6gc6AHtRRzkgfPW87K6EKUVV6t3Hu2hg7YkHkmMeLSfrP85x41" ) diff --git a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj index 58c56c6b2..a12ac8023 100644 --- a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj +++ b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj @@ -212,6 +212,7 @@ DC81B79F25BF2AA200F5A52C /* MVI.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC81B79E25BF2AA200F5A52C /* MVI.swift */; }; DC82EED629789853007A5853 /* TxHistoryExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC82EED529789853007A5853 /* TxHistoryExporter.swift */; }; DC89857F25914747007B253F /* UIApplicationState+Phoenix.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89857E25914747007B253F /* UIApplicationState+Phoenix.swift */; }; + DC8D94142CA7015F00EE844E /* ChannelFundingProblem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC8D94132CA7015F00EE844E /* ChannelFundingProblem.swift */; }; DC9130A02AE045FA00F9B8C6 /* Sequence+Sum.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC59377027516296003B4B53 /* Sequence+Sum.swift */; }; DC9473FA261270B4008D7242 /* MVI+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9473F9261270B4008D7242 /* MVI+Mock.swift */; }; DC949E6A2B45B1EC00E80BB5 /* LiquidityAdsHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC949E692B45B1EC00E80BB5 /* LiquidityAdsHelp.swift */; }; @@ -277,7 +278,6 @@ DCB511CE281AED58001BC525 /* phoenix-notifySrvExt.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DCB511C7281AED58001BC525 /* phoenix-notifySrvExt.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DCB5D2DF280879460020B8F5 /* DeviceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB5D2DE280879460020B8F5 /* DeviceInfo.swift */; }; DCB62F472A5DF19D00912A71 /* KotlinPublishers+Lightning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB62F462A5DF19D00912A71 /* KotlinPublishers+Lightning.swift */; }; - DCB62F492A5E09F900912A71 /* SpliceOutProblem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB62F482A5E09F900912A71 /* SpliceOutProblem.swift */; }; DCB876302735AA7300657570 /* UserDefaults+Serialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB8762F2735AA7300657570 /* UserDefaults+Serialization.swift */; }; DCB876322735AAB500657570 /* UserDefaults+Codable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB876312735AAB500657570 /* UserDefaults+Codable.swift */; }; DCBA371B2758076F00610EC8 /* SyncSeedManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA371A2758076F00610EC8 /* SyncSeedManager.swift */; }; @@ -617,6 +617,7 @@ DC81B79E25BF2AA200F5A52C /* MVI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MVI.swift; sourceTree = ""; }; DC82EED529789853007A5853 /* TxHistoryExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TxHistoryExporter.swift; sourceTree = ""; }; DC89857E25914747007B253F /* UIApplicationState+Phoenix.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplicationState+Phoenix.swift"; sourceTree = ""; }; + DC8D94132CA7015F00EE844E /* ChannelFundingProblem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelFundingProblem.swift; sourceTree = ""; }; DC9473F9261270B4008D7242 /* MVI+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MVI+Mock.swift"; sourceTree = ""; }; DC949E692B45B1EC00E80BB5 /* LiquidityAdsHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidityAdsHelp.swift; sourceTree = ""; }; DC98D3952AF170AC005BD177 /* PaymentWarningPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentWarningPopover.swift; sourceTree = ""; }; @@ -672,7 +673,6 @@ DCB511CB281AED58001BC525 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; DCB5D2DE280879460020B8F5 /* DeviceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfo.swift; sourceTree = ""; }; DCB62F462A5DF19D00912A71 /* KotlinPublishers+Lightning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KotlinPublishers+Lightning.swift"; sourceTree = ""; }; - DCB62F482A5E09F900912A71 /* SpliceOutProblem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpliceOutProblem.swift; sourceTree = ""; }; DCB8762F2735AA7300657570 /* UserDefaults+Serialization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+Serialization.swift"; sourceTree = ""; }; DCB876312735AAB500657570 /* UserDefaults+Codable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+Codable.swift"; sourceTree = ""; }; DCBA371A2758076F00610EC8 /* SyncSeedManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncSeedManager.swift; sourceTree = ""; }; @@ -995,7 +995,7 @@ DC1771B32ABC99CE00B286C7 /* WebsiteLinkPopover.swift */, DC98D3952AF170AC005BD177 /* PaymentWarningPopover.swift */, DCA02B9F2BD1A5FC0080520F /* ChannelSizeImpactWarning.swift */, - DCB62F482A5E09F900912A71 /* SpliceOutProblem.swift */, + DC8D94132CA7015F00EE844E /* ChannelFundingProblem.swift */, DC1718A62C20BF8A000CCAF5 /* PayOfferProblem.swift */, DC6F042C2C3DC4CD00627B4F /* ManualInput.swift */, ); @@ -1886,13 +1886,13 @@ DCE7233027B167240017CF56 /* SyncSeedManager_Actor.swift in Sources */, DCBA371B2758076F00610EC8 /* SyncSeedManager.swift in Sources */, DC39D4F12874DDF40030F18D /* View+If.swift in Sources */, - DCB62F492A5E09F900912A71 /* SpliceOutProblem.swift in Sources */, DC370A8B2B7FFFC70093C56F /* SwapInAddresses.swift in Sources */, 53BEFD54160278C5E393E319 /* HomeView.swift in Sources */, DC0C52662BF3C31700143831 /* WhichPinSheet.swift in Sources */, DCD1208728663F4A00EB39C5 /* TransactionsView.swift in Sources */, DC70A99C2BBB6093002DBFF8 /* InboundFeeWarning.swift in Sources */, DC118BFC27B4504B0080BBAC /* ScanView.swift in Sources */, + DC8D94142CA7015F00EE844E /* ChannelFundingProblem.swift in Sources */, DC46BAF326CACCF700E760A6 /* KotlinExtensions+Other.swift in Sources */, DC118C0427B454720080BBAC /* PaymentInFlightView.swift in Sources */, DCB5D2DF280879460020B8F5 /* DeviceInfo.swift in Sources */, diff --git a/phoenix-ios/phoenix-ios/Localizable.xcstrings b/phoenix-ios/phoenix-ios/Localizable.xcstrings index 999a5f166..bfae8aff1 100644 --- a/phoenix-ios/phoenix-ios/Localizable.xcstrings +++ b/phoenix-ios/phoenix-ios/Localizable.xcstrings @@ -1955,6 +1955,7 @@ } }, "%@ - from %@" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -2001,6 +2002,7 @@ } }, "%@ - to %@" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -2132,6 +2134,26 @@ } } }, + "%@ ∙ from %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ ∙ from %2$@" + } + } + } + }, + "%@ ∙ to %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ ∙ to %2$@" + } + } + } + }, "%@ days" : { "extractionState" : "manual", "localizations" : { @@ -3111,6 +3133,7 @@ }, "+%@ inbound liquidity" : { "comment" : "Payment description for inbound liquidity", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -7625,6 +7648,15 @@ } } } + }, + "Automated liquidity" : { + "comment" : "Payment description for inbound liquidity" + }, + "Automatic [FromChannelBalance]" : { + + }, + "Automatic [FromFutureHtlc]" : { + }, "Automatic Channel Creation" : { "extractionState" : "manual", @@ -9120,6 +9152,9 @@ } } } + }, + "Blockchain Info" : { + }, "blockchain tx" : { "extractionState" : "manual", @@ -9722,6 +9757,12 @@ } } } + }, + "caused by" : { + + }, + "Caused by" : { + }, "Chain mismatch" : { "comment" : "Error title", @@ -10008,6 +10049,9 @@ } } } + }, + "Channel Resized" : { + }, "Channel size impacted" : { "localizations" : { @@ -10210,6 +10254,7 @@ } }, "Channels initializing..." : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -23214,6 +23259,9 @@ } } } + }, + "Invalid channel parameters" : { + }, "Invalid date range" : { "localizations" : { @@ -24551,6 +24599,7 @@ }, "Lightning fees for routing the payment. Payment required %d hops." : { "comment" : "Fees explanation", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -24590,6 +24639,9 @@ } } }, + "Lightning fees for routing the payment. Payment required %lld hops." : { + "comment" : "Fees explanation" + }, "Lightning fees for routing the payment. Payment required 1 hop." : { "comment" : "Fees explanation", "localizations" : { @@ -24633,6 +24685,7 @@ }, "Lightning fees for routing the payment. Payment was divided into %d parts, using %d hops." : { "comment" : "Fees explanation", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -24672,6 +24725,17 @@ } } }, + "Lightning fees for routing the payment. Payment was divided into %lld parts, using %lld hops." : { + "comment" : "Fees explanation", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Lightning fees for routing the payment. Payment was divided into %1$lld parts, using %2$lld hops." + } + } + } + }, "Lightning invoice" : { "comment" : "Type of text being copied", "localizations" : { @@ -25079,6 +25143,7 @@ } }, "Liquidity Added" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -25761,6 +25826,9 @@ } } } + }, + "Manual" : { + }, "Manual Backup" : { "comment" : "Navigation bar title", @@ -25884,6 +25952,9 @@ } } }, + "Manual liquidity" : { + "comment" : "Payment description for inbound liquidity" + }, "Manual restore" : { "comment" : "Navigation bar title", "localizations" : { @@ -26939,6 +27010,7 @@ } }, "miner fees" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -27104,6 +27176,9 @@ } } } + }, + "Missing off-chain amount too low." : { + }, "Modify" : { "localizations" : { @@ -32555,6 +32630,9 @@ } } } + }, + "purchase type" : { + }, "QR code" : { "localizations" : { @@ -41780,6 +41858,9 @@ } } } + }, + "This liquidity was required to receive a payment" : { + }, "This means you will not be able to receive payments when Phoenix is in the background. To receive payments, Phoenix must be open and in the foreground." : { "extractionState" : "manual", @@ -43968,6 +44049,9 @@ } } } + }, + "Unexpected message" : { + }, "Unknown" : { "comment" : "Connection state", diff --git a/phoenix-ios/phoenix-ios/extensions/String+Substring.swift b/phoenix-ios/phoenix-ios/extensions/String+Substring.swift index 08ba0e566..e8865fd91 100644 --- a/phoenix-ios/phoenix-ios/extensions/String+Substring.swift +++ b/phoenix-ios/phoenix-ios/extensions/String+Substring.swift @@ -12,4 +12,8 @@ extension String { let to = index(startIndex, offsetBy: start + limitedLength) return String(self[from.. String { + return substring(location: 0, length: length) + } } diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift index 625d98e06..340cbc40d 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift @@ -163,11 +163,17 @@ extension WalletPaymentInfo { return String(localized: "Bump fees", comment: "Payment description for splice CPFP") } else if let il = outgoingPayment as? Lightning_kmpInboundLiquidityOutgoingPayment { - let amount = Utils.formatBitcoin(sat: il._lease.amount, bitcoinUnit: .sat) - return String( - localized: "+\(amount.string) inbound liquidity", - comment: "Payment description for inbound liquidity" - ) + if il.isManualPurchase() { + return String( + localized: "Manual liquidity", + comment: "Payment description for inbound liquidity" + ) + } else { + return String( + localized: "Automated liquidity", + comment: "Payment description for inbound liquidity" + ) + } } } @@ -276,6 +282,27 @@ extension Lightning_kmpIncomingPayment { } } } + + var lightningPaymentFundingTxId: Bitcoin_kmpTxId? { + + guard let received else { + return nil + } + + for rw in received.receivedWith { + if let lp = rw as? Lightning_kmpIncomingPayment.ReceivedWith_LightningPayment, + let txId = lp.fundingFee?.fundingTxId + { + return txId + } + } + + return nil + } + + var isLightningPaymentWithFundingTxId: Bool { + return lightningPaymentFundingTxId != nil + } } extension Lightning_kmpIncomingPayment.Received { diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinTypes.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinTypes.swift index d68ed93ea..c5480e561 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinTypes.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinTypes.swift @@ -102,3 +102,13 @@ extension LnurlAuth { typealias Scheme_DEFAULT = LnurlAuth.SchemeDEFAULT_SCHEME typealias Scheme_ANDROID_LEGACY = LnurlAuth.SchemeANDROID_LEGACY_SCHEME } + +extension Lightning_kmpIncomingPayment { + + typealias ReceivedWith_LightningPayment = ReceivedWithLightningPayment + typealias ReceivedWith_AddedToFeeCredit = ReceivedWithAddedToFeeCredit + typealias ReceivedWith_OnChainIncomingPayment = ReceivedWithOnChainIncomingPayment + + typealias ReceivedWith_SpliceIn = ReceivedWithSpliceIn + typealias ReceivedWith_NewChannel = ReceivedWithNewChannel +} diff --git a/phoenix-ios/phoenix-ios/officers/BusinessManager.swift b/phoenix-ios/phoenix-ios/officers/BusinessManager.swift index 7d9d04767..ac5e5412b 100644 --- a/phoenix-ios/phoenix-ios/officers/BusinessManager.swift +++ b/phoenix-ios/phoenix-ios/officers/BusinessManager.swift @@ -93,7 +93,7 @@ class BusinessManager { private init() { // must use shared instance business = PhoenixBusiness(ctx: PlatformContext.default) - BusinessManager._isTestnet = business.chain.isTestnet() + BusinessManager._isTestnet = !business.chain.isMainnet() let nc = NotificationCenter.default diff --git a/phoenix-ios/phoenix-ios/officers/WalletReset.swift b/phoenix-ios/phoenix-ios/officers/WalletReset.swift index d649f08f2..8e870a540 100644 --- a/phoenix-ios/phoenix-ios/officers/WalletReset.swift +++ b/phoenix-ios/phoenix-ios/officers/WalletReset.swift @@ -170,8 +170,8 @@ class WalletReset { let dbDir = groupDir.appendingPathComponent("databases", isDirectory: true) - let chainName = Biz.business.chain.name.lowercased() - let nodeIdHash = Biz.nodeIdHash ?? "nil" + let chainName: String = Biz.business.chain.phoenixName + let nodeIdHash: String = Biz.nodeIdHash ?? "nil" log.debug("dbDir: \(dbDir.path)") log.debug("chainName: \(chainName)") diff --git a/phoenix-ios/phoenix-ios/prefs/Prefs+BackupSeed.swift b/phoenix-ios/phoenix-ios/prefs/Prefs+BackupSeed.swift index 3b524a302..cd075208b 100644 --- a/phoenix-ios/phoenix-ios/prefs/Prefs+BackupSeed.swift +++ b/phoenix-ios/phoenix-ios/prefs/Prefs+BackupSeed.swift @@ -16,6 +16,17 @@ class Prefs_BackupSeed { return Prefs.shared.defaults } + /// Updating publishers should always be done on the main thread. + /// Otherwise we risk updating UI components on a background thread, which is dangerous. + /// + private func runOnMainThread(_ block: @escaping () -> Void) { + if Thread.isMainThread { + block() + } else { + DispatchQueue.main.async { block() } + } + } + lazy private(set) var isEnabled_publisher: CurrentValueSubject = { return CurrentValueSubject(self.isEnabled) }() @@ -32,7 +43,9 @@ class Prefs_BackupSeed { set { let key = Key.backupSeed_enabled.rawValue defaults.set(newValue, forKey: key) - isEnabled_publisher.send(newValue) + runOnMainThread { + self.isEnabled_publisher.send(newValue) + } } } @@ -57,7 +70,9 @@ class Prefs_BackupSeed { } else { defaults.removeObject(forKey: key) } - hasUploadedSeed_publisher.send() + runOnMainThread { + self.hasUploadedSeed_publisher.send() + } } lazy private(set) var name_publisher: PassthroughSubject = { @@ -86,7 +101,10 @@ class Prefs_BackupSeed { defaults.setValue(newValue, forKey: key) } setHasUploadedSeed(false, encryptedNodeId: encryptedNodeId) - name_publisher.send() + runOnMainThread { + self.name_publisher.send() + } + } } @@ -111,7 +129,9 @@ class Prefs_BackupSeed { } else { defaults.removeObject(forKey: key) } - manualBackup_taskDone_publisher.send() + runOnMainThread { + self.manualBackup_taskDone_publisher.send() + } } func resetWallet(encryptedNodeId: String) { @@ -122,7 +142,9 @@ class Prefs_BackupSeed { defaults.removeObject(forKey: manualBackup_taskDone_key(encryptedNodeId)) // Reset any publishers with stored state - isEnabled_publisher.send(self.isEnabled) + runOnMainThread { + self.isEnabled_publisher.send(self.isEnabled) + } } } diff --git a/phoenix-ios/phoenix-ios/prefs/Prefs+BackupTransactions.swift b/phoenix-ios/phoenix-ios/prefs/Prefs+BackupTransactions.swift index b1e3a44ed..0b402e179 100644 --- a/phoenix-ios/phoenix-ios/prefs/Prefs+BackupTransactions.swift +++ b/phoenix-ios/phoenix-ios/prefs/Prefs+BackupTransactions.swift @@ -27,6 +27,17 @@ class Prefs_BackupTransactions { return Prefs.shared.defaults } + /// Updating publishers should always be done on the main thread. + /// Otherwise we risk updating UI components on a background thread, which is dangerous. + /// + private func runOnMainThread(_ block: @escaping () -> Void) { + if Thread.isMainThread { + block() + } else { + DispatchQueue.main.async { block() } + } + } + lazy private(set) var isEnabledPublisher: CurrentValueSubject = { return CurrentValueSubject(self.isEnabled) }() @@ -43,7 +54,9 @@ class Prefs_BackupTransactions { set { let key = Key.backupTransactions_enabled.rawValue defaults.set(newValue, forKey: key) - isEnabledPublisher.send(newValue) + runOnMainThread { + self.isEnabledPublisher.send(newValue) + } } } @@ -128,6 +141,8 @@ class Prefs_BackupTransactions { defaults.removeObject(forKey: Key.backupTransactions_useUploadDelay.rawValue) // Reset any publishers with stored state - isEnabledPublisher.send(self.isEnabled) + runOnMainThread { + self.isEnabledPublisher.send(self.isEnabled) + } } } diff --git a/phoenix-ios/phoenix-ios/prefs/UserDefaults+Codable.swift b/phoenix-ios/phoenix-ios/prefs/UserDefaults+Codable.swift index a6c0b74de..90f3b2dd8 100644 --- a/phoenix-ios/phoenix-ios/prefs/UserDefaults+Codable.swift +++ b/phoenix-ios/phoenix-ios/prefs/UserDefaults+Codable.swift @@ -106,10 +106,26 @@ struct LiquidityPolicy: Equatable, Codable { ) } + var effectiveInboundLiquidityTargetSats: Int64? { + return NodeParamsManager.companion.defaultLiquidityPolicy.inboundLiquidityTarget?.sat + } + + var effectiveInboundLiquidityTarget: Bitcoin_kmpSatoshi? { + if let sats = effectiveInboundLiquidityTargetSats { + return Bitcoin_kmpSatoshi(sat: sats) + } else { + return nil + } + } + var effectiveMaxFeeSats: Int64 { return maxFeeSats ?? NodeParamsManager.companion.defaultLiquidityPolicy.maxAbsoluteFee.sat } + var effectiveMaxFee: Bitcoin_kmpSatoshi { + return Bitcoin_kmpSatoshi(sat: effectiveMaxFeeSats) + } + var effectiveMaxFeeBasisPoints: Int32 { return maxFeeBasisPoints ?? NodeParamsManager.companion.defaultLiquidityPolicy.maxRelativeFeeBasisPoints } @@ -118,14 +134,20 @@ struct LiquidityPolicy: Equatable, Codable { return skipAbsoluteFeeCheck ?? NodeParamsManager.companion.defaultLiquidityPolicy.skipAbsoluteFeeCheck } + var effectiveMaxAllowedFeeCredit: Lightning_kmpMilliSatoshi { + return NodeParamsManager.companion.defaultLiquidityPolicy.maxAllowedFeeCredit + } + func toKotlin() -> Lightning_kmpLiquidityPolicy { if enabled { return Lightning_kmpLiquidityPolicy.Auto( - maxAbsoluteFee: Bitcoin_kmpSatoshi(sat: effectiveMaxFeeSats), + inboundLiquidityTarget: effectiveInboundLiquidityTarget, + maxAbsoluteFee: effectiveMaxFee, maxRelativeFeeBasisPoints: effectiveMaxFeeBasisPoints, - skipAbsoluteFeeCheck: effectiveSkipAbsoluteFeeCheck + skipAbsoluteFeeCheck: effectiveSkipAbsoluteFeeCheck, + maxAllowedFeeCredit: effectiveMaxAllowedFeeCredit ) } else { diff --git a/phoenix-ios/phoenix-ios/sync/SyncSeedManager.swift b/phoenix-ios/phoenix-ios/sync/SyncSeedManager.swift index 911449e3d..2552cc776 100644 --- a/phoenix-ios/phoenix-ios/sync/SyncSeedManager.swift +++ b/phoenix-ios/phoenix-ios/sync/SyncSeedManager.swift @@ -756,20 +756,10 @@ class SyncSeedManager: SyncManagerProtcol { private class func record_table_name(chain: Bitcoin_kmpChain) -> String { - // From Apple's docs: - // > A record type must consist of one or more alphanumeric characters - // > and must start with a letter. CloudKit permits the use of underscores, - // > but not spaces. - // - var allowed = CharacterSet.alphanumerics - allowed.insert("_") - - let suffix = chain.name.lowercased().components(separatedBy: allowed.inverted).joined(separator: "") - // E.g.: // - seeds_bitcoin_testnet // - seeds_bitcoin_mainnet - return "seeds_bitcoin_\(suffix)" + return "seeds_bitcoin_\(chain.phoenixName)" } private func recordID() -> CKRecord.ID { diff --git a/phoenix-ios/phoenix-ios/views/configuration/fees/liquidity management/LiquidityAdsView.swift b/phoenix-ios/phoenix-ios/views/configuration/fees/liquidity management/LiquidityAdsView.swift index 10be646bc..f06ec01f8 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/fees/liquidity management/LiquidityAdsView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/fees/liquidity management/LiquidityAdsView.swift @@ -24,7 +24,7 @@ struct LiquidityAdsView: View { @State var sliderValue: Double = 0 @State var feeInfo: LiquidityFeeInfo? = nil - @State var finalResult: Lightning_kmpChannelCommand.CommitmentSpliceResponse? = nil + @State var finalResult: Lightning_kmpChannelFundingResponse? = nil @State var isEstimating: Bool = false @State var isPurchasing: Bool = false @@ -474,12 +474,12 @@ struct LiquidityAdsView: View { @ViewBuilder func section_result( - _ finalResult: Lightning_kmpChannelCommand.CommitmentSpliceResponse + _ finalResult: Lightning_kmpChannelFundingResponse ) -> some View { Section { - if let _ = finalResult.asCreated() { - section_result_created() + if let _ = finalResult.asSuccess() { + section_result_success() } else if let failure = finalResult.asFailure() { section_result_failure(failure) } @@ -487,7 +487,7 @@ struct LiquidityAdsView: View { } @ViewBuilder - func section_result_created() -> some View { + func section_result_success() -> some View { VStack(alignment: HorizontalAlignment.center, spacing: 0) { @@ -535,7 +535,7 @@ struct LiquidityAdsView: View { @ViewBuilder func section_result_failure( - _ failure: Lightning_kmpChannelCommand.CommitmentSpliceResponseFailure + _ failure: Lightning_kmpChannelFundingResponse.Failure ) -> some View { VStack(alignment: HorizontalAlignment.center, spacing: 0) { @@ -585,7 +585,7 @@ struct LiquidityAdsView: View { @ViewBuilder func section_result_failure_details( - _ failure: Lightning_kmpChannelCommand.CommitmentSpliceResponseFailure + _ failure: Lightning_kmpChannelFundingResponse.Failure ) -> some View { Group { @@ -595,10 +595,12 @@ struct LiquidityAdsView: View { Text("Invalid splice-out pubKeyScript") } else if let _ = failure.asSpliceAlreadyInProgress() { Text("Splice already in progress") - } else if let _ = failure.asChannelNotQuiescent() { - Text("Splice has been aborted") } else if let _ = failure.asConcurrentRemoteSplice() { Text("Concurrent splice in progress") + } else if let _ = failure.asChannelNotQuiescent() { + Text("Splice has been aborted") + } else if let _ = failure.asInvalidChannelParameters() { + Text("Invalid channel parameters") } else if let _ = failure.asInvalidLiquidityAds() { Text("Invalid liquidity ads") } else if let _ = failure.asFundingFailure() { @@ -611,6 +613,8 @@ struct LiquidityAdsView: View { Text("Cannot create commit tx") } else if let _ = failure.asAbortedByPeer() { Text("Aborted by peer") + } else if let _ = failure.asUnexpectedMessage() { + Text("Unexpected message") } else if let _ = failure.asDisconnected() { Text("Disconnected") } else { @@ -791,30 +795,38 @@ struct LiquidityAdsView: View { let feePerByte = Lightning_kmpFeeratePerByte(feerate: satsPerByte) let feePerKw = Lightning_kmpFeeratePerKw(feeratePerByte: feePerByte) - let leaseRate = NodeParamsManager.companion._liquidityLeaseRate(amount: amount) - isEstimating = true Task { @MainActor in + var fundingRate: Lightning_kmpLiquidityAdsFundingRate? = nil + do { + fundingRate = try await peer.fundingRate(amount: amount) + } catch { + log.error("peer.fundingRate(amount): error: \(error)") + } + var pair: KotlinPair< Lightning_kmpFeeratePerKw, Lightning_kmpChannelManagementFees>? = nil var _channelsNotAvailable = false - do { - pair = try await peer._estimateFeeForInboundLiquidity( - amount: amount, - targetFeerate: feePerKw, - leaseRate: leaseRate - ) - - if pair == nil { - log.error("peer.estimateFeeForInboundLiquidity() == nil") - _channelsNotAvailable = true + + if let fundingRate { + do { + pair = try await peer.estimateFeeForInboundLiquidity( + amount: amount, + targetFeerate: feePerKw, + fundingRate: fundingRate + ) + + if pair == nil { + log.error("peer.estimateFeeForInboundLiquidity() == nil") + _channelsNotAvailable = true + } + + } catch { + log.error("peer.estimateFeeForInboundLiquidity(): error: \(error)") } - - } catch { - log.error("peer.estimateFeeForInboundLiquidity(): error: \(error)") } let currentAmount = self.selectedLiquidityAmount() @@ -826,10 +838,11 @@ struct LiquidityAdsView: View { if let pair = pair, let feerate: Lightning_kmpFeeratePerKw = pair.first, - let fees: Lightning_kmpChannelManagementFees = pair.second + let fees: Lightning_kmpChannelManagementFees = pair.second, + let fundingRate = fundingRate { feeInfo = LiquidityFeeInfo( - params: LiquidityFeeParams(amount: amount, feerate: feerate, leaseRate: leaseRate), + params: LiquidityFeeParams(amount: amount, feerate: feerate, fundingRate: fundingRate), estimate: LiquidityFeeEstimate(minerFee: fees.miningFee, serviceFee: fees.serviceFee) ) } @@ -853,13 +866,13 @@ struct LiquidityAdsView: View { isPurchasing = true Task { @MainActor in - var result: Lightning_kmpChannelCommand.CommitmentSpliceResponse? = nil + var result: Lightning_kmpChannelFundingResponse? = nil var _channelsNotAvailable = false do { - result = try await peer._requestInboundLiquidity( + result = try await peer.requestInboundLiquidity( amount: feeInfo.params.amount, feerate: feeInfo.params.feerate, - leaseRate: feeInfo.params.leaseRate + fundingRate: feeInfo.params.fundingRate ) if result == nil { @@ -874,12 +887,12 @@ struct LiquidityAdsView: View { if let result { finalResult = result } else if !_channelsNotAvailable { - finalResult = Lightning_kmpChannelCommand.CommitmentSpliceResponseFailureDisconnected() + finalResult = Lightning_kmpChannelFundingResponse.FailureDisconnected() } channelsNotAvailable = _channelsNotAvailable isPurchasing = false - isPurchased = (result?.asCreated() != nil) + isPurchased = (result?.asSuccess() != nil) } // } diff --git a/phoenix-ios/phoenix-ios/views/configuration/fees/liquidity management/LiquidityFeeInfo.swift b/phoenix-ios/phoenix-ios/views/configuration/fees/liquidity management/LiquidityFeeInfo.swift index 741809b8a..9c5875d04 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/fees/liquidity management/LiquidityFeeInfo.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/fees/liquidity management/LiquidityFeeInfo.swift @@ -4,7 +4,7 @@ import PhoenixShared struct LiquidityFeeParams { let amount: Bitcoin_kmpSatoshi let feerate: Lightning_kmpFeeratePerKw - let leaseRate: LiquidityAds_LeaseRate + let fundingRate: Lightning_kmpLiquidityAdsFundingRate } struct LiquidityFeeEstimate { diff --git a/phoenix-ios/phoenix-ios/views/inspect/CpfpView.swift b/phoenix-ios/phoenix-ios/views/inspect/CpfpView.swift index d04883a2a..cc404ac4e 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/CpfpView.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/CpfpView.swift @@ -27,7 +27,7 @@ enum CpfpError: Error { case feeNotIncreased case noChannels case errorThrown(message: String) - case executeError(problem: SpliceOutProblem) + case executeError(problem: ChannelFundingProblem) } @@ -715,7 +715,7 @@ struct CpfpView: View { feerate: feeratePerKw ) - if let problem = SpliceOutProblem.fromResponse(response) { + if let problem = ChannelFundingProblem.fromResponse(response) { self.cpfpError = .executeError(problem: problem) } else { switch location { diff --git a/phoenix-ios/phoenix-ios/views/inspect/DetailsView.swift b/phoenix-ios/phoenix-ios/views/inspect/DetailsView.swift index fb7e9186f..2d4d41195 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/DetailsView.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/DetailsView.swift @@ -12,10 +12,13 @@ struct DetailsView: View { let location: PaymentView.Location @Binding var paymentInfo: WalletPaymentInfo + @Binding var liquidityPayment: Lightning_kmpInboundLiquidityOutgoingPayment? @Binding var showOriginalFiatValue: Bool @Binding var showFiatValueExplanation: Bool + let switchToPayment: (_ paymentId: WalletPaymentId) -> Void + @Environment(\.presentationMode) var presentationMode: Binding @ViewBuilder @@ -48,8 +51,10 @@ struct DetailsView: View { DetailsInfoGrid( paymentInfo: $paymentInfo, + liquidityPayment: $liquidityPayment, showOriginalFiatValue: $showOriginalFiatValue, - showFiatValueExplanation: $showFiatValueExplanation + showFiatValueExplanation: $showFiatValueExplanation, + switchToPayment: switchToPayment ) } .background(Color.primaryBackground) @@ -91,9 +96,13 @@ struct DetailsView: View { fileprivate struct DetailsInfoGrid: InfoGridView { @Binding var paymentInfo: WalletPaymentInfo + @Binding var liquidityPayment: Lightning_kmpInboundLiquidityOutgoingPayment? + @Binding var showOriginalFiatValue: Bool @Binding var showFiatValueExplanation: Bool + let switchToPayment: (_ paymentId: WalletPaymentId) -> Void + @State var showBlockchainExplorerOptions = false @State var truncatedText: [String: Bool] = [:] @@ -119,6 +128,8 @@ fileprivate struct DetailsInfoGrid: InfoGridView { } // + @Environment(\.presentationMode) var presentationMode: Binding + @EnvironmentObject var currencyPrefs: CurrencyPrefs // -------------------------------------------------- @@ -177,6 +188,7 @@ fileprivate struct DetailsInfoGrid: InfoGridView { common_amountReceived(msat: received.amount) payment_standardFees(incomingPayment) payment_minerFees(incomingPayment) + payment_serviceFees(incomingPayment) } let receivedWithArray = received.receivedWith.sorted { $0.hash < $1.hash } @@ -268,7 +280,7 @@ fileprivate struct DetailsInfoGrid: InfoGridView { onChain_broadcastAt(spliceOut) onChain_confirmedAt(spliceOut) common_amountSent(msat: outgoingPayment.amount) - onChain_minerFees(spliceOut) + payment_minerFees(spliceOut) common_amountReceived(sat: spliceOut.recipientAmount) onChain_btcTxid(spliceOut) } @@ -301,7 +313,7 @@ fileprivate struct DetailsInfoGrid: InfoGridView { } content: { onChain_broadcastAt(spliceCpfp) onChain_confirmedAt(spliceCpfp) - onChain_minerFees(spliceCpfp) + payment_minerFees(spliceCpfp) onChain_btcTxid(spliceCpfp) } @@ -310,12 +322,21 @@ fileprivate struct DetailsInfoGrid: InfoGridView { InlineSection { header("Inbound Liquidity") } content: { + liquidityPayment_purchaseType(liquidityPayment) + liquidityPayment_causedBy(liquidityPayment) liquidityPayment_liqudityAmount(liquidityPayment) payment_minerFees(outgoingPayment) payment_serviceFees(outgoingPayment) - liquidityPayment_spliceTxid(liquidityPayment) liquidityPayment_channelId(liquidityPayment) } + + InlineSection { + header("Blockchain Info") + } content: { + onChain_broadcastAt(liquidityPayment) + onChain_confirmedAt(liquidityPayment) + liquidityPayment_spliceTxid(liquidityPayment) + } } } @@ -357,7 +378,7 @@ fileprivate struct DetailsInfoGrid: InfoGridView { } // -------------------------------------------------- - // MARK: View Builders: Rows + // MARK: View Builders: Detailed Rows // -------------------------------------------------- @ViewBuilder @@ -689,7 +710,7 @@ fileprivate struct DetailsInfoGrid: InfoGridView { ) -> some View { let identifier: String = #function - if let standardFees = payment.standardFees(), standardFees.0 > 0 { + if let standardFees = standardFees(), standardFees.0 > 0 { InfoGridRowWrapper( identifier: identifier, @@ -717,7 +738,7 @@ fileprivate struct DetailsInfoGrid: InfoGridView { ) -> some View { let identifier: String = #function - if let minerFees = payment.minerFees(), minerFees.0 > 0 { + if let minerFees = minerFees(), minerFees.0 > 0 { InfoGridRowWrapper( identifier: identifier, @@ -745,7 +766,7 @@ fileprivate struct DetailsInfoGrid: InfoGridView { ) -> some View { let identifier: String = #function - if let serviceFees = payment.serviceFees(), serviceFees.0 > 0 { + if let serviceFees = serviceFees(), serviceFees.0 > 0 { InfoGridRowWrapper( identifier: identifier, @@ -983,30 +1004,6 @@ fileprivate struct DetailsInfoGrid: InfoGridView { } } - @ViewBuilder - func onChain_minerFees( - _ onChain: Lightning_kmpOnChainOutgoingPayment - ) -> some View { - let identifier: String = #function - - InfoGridRowWrapper( - identifier: identifier, - keyColumnWidth: keyColumnWidth(identifier: identifier) - ) { - keyColumn("miner fees") - - } valueColumn: { - - commonValue_amounts( - identifier: identifier, - displayAmounts: displayAmounts( - sat: onChain.miningFees, - originalFiat: paymentInfo.metadata.originalFiat - ) - ) - } - } - @ViewBuilder func onChain_btcTxid( _ onChain: Lightning_kmpOnChainOutgoingPayment @@ -1073,6 +1070,57 @@ fileprivate struct DetailsInfoGrid: InfoGridView { } // } + @ViewBuilder + func liquidityPayment_purchaseType( + _ payment: Lightning_kmpInboundLiquidityOutgoingPayment + ) -> some View { + let identifier: String = #function + + InfoGridRowWrapper( + identifier: identifier, + keyColumnWidth: keyColumnWidth(identifier: identifier) + ) { + keyColumn("purchase type") + + } valueColumn: { + + if payment.isManualPurchase() { + Text("Manual") + } else if payment.isPaidInTheFuture() { + Text("Automatic [FromFutureHtlc]") + } else { + Text("Automatic [FromChannelBalance]") + } + } + } + + @ViewBuilder + func liquidityPayment_causedBy( + _ payment: Lightning_kmpInboundLiquidityOutgoingPayment + ) -> some View { + let identifier: String = #function + + if let paymentId = payment.relatedPaymentIds().first { + + InfoGridRowWrapper( + identifier: identifier, + keyColumnWidth: keyColumnWidth(identifier: identifier) + ) { + keyColumn("caused by") + + } valueColumn: { + + Button { + requestSwitchToPayment(paymentId) + } label: { + Text(paymentId.dbId) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + } + @ViewBuilder func liquidityPayment_liqudityAmount( _ payment: Lightning_kmpInboundLiquidityOutgoingPayment @@ -1090,7 +1138,7 @@ fileprivate struct DetailsInfoGrid: InfoGridView { commonValue_amounts( identifier: identifier, displayAmounts: displayAmounts( - sat: payment._lease.amount, + sat: payment.purchase.amount, originalFiat: paymentInfo.metadata.originalFiat ) ) @@ -1113,6 +1161,10 @@ fileprivate struct DetailsInfoGrid: InfoGridView { common_channelId(payment.channelId) } + // -------------------------------------------------- + // MARK: View Builders: Common Rows + // -------------------------------------------------- + @ViewBuilder func common_amountSent( msat: Lightning_kmpMilliSatoshi @@ -1260,7 +1312,7 @@ fileprivate struct DetailsInfoGrid: InfoGridView { } // -------------------------------------------------- - // MARK: View Builders: Values + // MARK: View Builders: Common Values // -------------------------------------------------- @ViewBuilder @@ -1433,6 +1485,45 @@ fileprivate struct DetailsInfoGrid: InfoGridView { // MARK: View Helpers // -------------------------------------------------- + func standardFees() -> (Int64, String, String)? { + + return paymentInfo.payment.standardFees() + } + + func minerFees() -> (Int64, String, String)? { + + if let liquidity = paymentInfo.payment as? Lightning_kmpInboundLiquidityOutgoingPayment, + liquidity.isPaidInTheFuture() { + // We don't display the fees here. + // Instead we're displaying the fees on the corresponding IncomingPayment. + return nil + } else if let result = paymentInfo.payment.minerFees() { + return result + } else if let liquidityPayment, liquidityPayment.isPaidInTheFuture() { + // This is the corresponding IncomingPayment, and we have the linked liquidityPayment. + return liquidityPayment.minerFees() + } else { + return nil + } + } + + func serviceFees() -> (Int64, String, String)? { + + if let liquidity = paymentInfo.payment as? Lightning_kmpInboundLiquidityOutgoingPayment, + liquidity.isPaidInTheFuture() { + // We don't display the fees here. + // Instead we're displaying the fees on the corresponding IncomingPayment. + return nil + } else if let result = paymentInfo.payment.serviceFees() { + return result + } else if let liquidityPayment, liquidityPayment.isPaidInTheFuture() { + // This is the corresponding IncomingPayment, and we have the linked liquidityPayment. + return liquidityPayment.serviceFees() + } else { + return nil + } + } + func displayTimes(date: Date) -> (String, String) { let df = DateFormatter() @@ -1599,6 +1690,13 @@ fileprivate struct DetailsInfoGrid: InfoGridView { // MARK: Actions // -------------------------------------------------- + func requestSwitchToPayment(_ paymentId: WalletPaymentId) { + log.trace("requestSwitchToPayment()") + + presentationMode.wrappedValue.dismiss() + switchToPayment(paymentId) + } + func exploreTx(_ txId: Bitcoin_kmpTxId, website: BlockchainExplorer.Website) { log.trace("exploreTX()") diff --git a/phoenix-ios/phoenix-ios/views/inspect/SummaryInfoGrid.swift b/phoenix-ios/phoenix-ios/views/inspect/SummaryInfoGrid.swift index cc03f380f..c4c5dd952 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/SummaryInfoGrid.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/SummaryInfoGrid.swift @@ -11,9 +11,12 @@ fileprivate var log = LoggerFactory.shared.logger(filename, .warning) struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture discussion @Binding var paymentInfo: WalletPaymentInfo - @Binding var showOriginalFiatValue: Bool + @Binding var showOriginalFiatValue: Bool + + @Binding var liquidityPayment: Lightning_kmpInboundLiquidityOutgoingPayment? let showContactView: (_ contact: ContactInfo) -> Void + let switchToPayment: (_ paymentId: WalletPaymentId) -> Void // let minKeyColumnWidth: CGFloat = 50 @@ -42,10 +45,15 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc @State var popoverPresent_standardFees = false @State var popoverPresent_minerFees = false @State var popoverPresent_serviceFees = false + @State var popoverPresent_liquidityCause = false @Environment(\.openURL) var openURL @EnvironmentObject var currencyPrefs: CurrencyPrefs + // -------------------------------------------------- + // MARK: View Builders + // -------------------------------------------------- + @ViewBuilder var infoGridRows: some View { @@ -70,11 +78,18 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc paymentFeesRow_StandardFees() paymentFeesRow_MinerFees() paymentFeesRow_ServiceFees() - paymentDurationRow() + + causedByRow() + + // How do we detect leased liquidity now ? + // paymentDurationRow() paymentErrorRow() } .padding([.leading, .trailing]) + .onAppear { + onAppear() + } } @ViewBuilder @@ -470,7 +485,7 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc @ViewBuilder func paymentFeesRow_StandardFees() -> some View { - if let standardFees = paymentInfo.payment.standardFees() { + if let standardFees = standardFees() { paymentFeesRow( msat: standardFees.0, title: standardFees.1, @@ -483,7 +498,7 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc @ViewBuilder func paymentFeesRow_MinerFees() -> some View { - if let minerFees = paymentInfo.payment.minerFees() { + if let minerFees = minerFees() { paymentFeesRow( msat: minerFees.0, title: minerFees.1, @@ -496,7 +511,7 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc @ViewBuilder func paymentFeesRow_ServiceFees() -> some View { - if let serviceFees = paymentInfo.payment.serviceFees() { + if let serviceFees = serviceFees() { paymentFeesRow( msat: serviceFees.0, title: serviceFees.1, @@ -606,6 +621,54 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc } } + @ViewBuilder + func causedByRow() -> some View { + let identifier: String = #function + + if let liquidity = paymentInfo.payment as? Lightning_kmpInboundLiquidityOutgoingPayment, + let paymentId = liquidity.relatedPaymentIds().first + { + InfoGridRow( + identifier: identifier, + vAlignment: .firstTextBaseline, + hSpacing: horizontalSpacingBetweenColumns, + keyColumnWidth: keyColumnWidth(identifier: identifier), + keyColumnAlignment: .trailing + ) { + + keyColumn("Caused by") + + } valueColumn: { + + HStack(alignment: VerticalAlignment.center, spacing: 6) { + + Button { + switchToPayment(paymentId) + } label: { + Text(paymentId.dbId) + .lineLimit(1) + .truncationMode(.middle) + } + + Button { + popoverPresent_liquidityCause.toggle() + } label: { + Image(systemName: "questionmark.circle") + .renderingMode(.template) + .foregroundColor(.secondary) + .font(.body) + } + .popover(present: $popoverPresent_liquidityCause) { + InfoPopoverWindow { + Text("This liquidity was required to receive a payment") + } + } + } + + } // + } + } + @ViewBuilder func paymentErrorRow() -> some View { let identifier: String = #function @@ -630,10 +693,70 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc } } + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + func onAppear() { + log.trace("onAppear()") + + if let liquidity = paymentInfo.payment as? Lightning_kmpInboundLiquidityOutgoingPayment { + log.debug("is: Lightning_kmpInboundLiquidityOutgoingPayment") + + if let paymentId = liquidity.relatedPaymentIds().first { + log.debug("paymentId = \(paymentId.dbId)") + } else { + log.debug("paymentId = nil") + } + + } else { + log.debug("is NOT: Lightning_kmpInboundLiquidityOutgoingPayment") + } + } + // -------------------------------------------------- // MARK: Utilities // -------------------------------------------------- + func standardFees() -> (Int64, String, String)? { + + return paymentInfo.payment.standardFees() + } + + func minerFees() -> (Int64, String, String)? { + + if let liquidity = paymentInfo.payment as? Lightning_kmpInboundLiquidityOutgoingPayment, + liquidity.isPaidInTheFuture() { + // We don't display the fees here. + // Instead we're displaying the fees on the corresponding IncomingPayment. + return nil + } else if let result = paymentInfo.payment.minerFees() { + return result + } else if let liquidityPayment, liquidityPayment.isPaidInTheFuture() { + // This is the corresponding IncomingPayment, and we have the linked liquidityPayment. + return liquidityPayment.minerFees() + } else { + return nil + } + } + + func serviceFees() -> (Int64, String, String)? { + + if let liquidity = paymentInfo.payment as? Lightning_kmpInboundLiquidityOutgoingPayment, + liquidity.isPaidInTheFuture() { + // We don't display the fees here. + // Instead we're displaying the fees on the corresponding IncomingPayment. + return nil + } else if let result = paymentInfo.payment.serviceFees() { + return result + } else if let liquidityPayment, liquidityPayment.isPaidInTheFuture() { + // This is the corresponding IncomingPayment, and we have the linked liquidityPayment. + return liquidityPayment.serviceFees() + } else { + return nil + } + } + func formattedAmount(msat: Int64) -> FormattedAmount { if showOriginalFiatValue && currencyPrefs.currencyType == .fiat { diff --git a/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift b/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift index 0512cf283..03614c68d 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift @@ -1,6 +1,7 @@ import SwiftUI import PhoenixShared import Popovers +import Combine fileprivate let filename = "SummaryView" #if DEBUG && true @@ -32,6 +33,8 @@ struct SummaryView: View { @State var paymentInfo: WalletPaymentInfo @State var paymentInfoIsStale: Bool + @State var liquidityPayment: Lightning_kmpInboundLiquidityOutgoingPayment? = nil + let fetchOptions = WalletPaymentFetchOptions.companion.All @State var blockchainConfirmations: Int? = nil @@ -44,9 +47,9 @@ struct SummaryView: View { @State var didAppear = false - @State var buttonListTruncationDetected_standard: Bool = false - @State var buttonListTruncationDetected_squeezed: Bool = false - @State var buttonListTruncationDetected_compact: Bool = false + @State var buttonListTruncationDetection_standard: [DynamicTypeSize: Bool] = [:] + @State var buttonListTruncationDetection_squeezed: [DynamicTypeSize: Bool] = [:] + @State var buttonListTruncationDetection_compact: [DynamicTypeSize: Bool] = [:] // @State var navLinkTag: NavLinkTag? = nil @@ -67,6 +70,9 @@ struct SummaryView: View { ) @State var buttonHeight: CGFloat? = nil + @StateObject var blockchainMonitorState = BlockchainMonitorState() + + @Environment(\.dynamicTypeSize) var dynamicTypeSize: DynamicTypeSize @Environment(\.presentationMode) var presentationMode: Binding @EnvironmentObject var navCoordinator: NavigationCoordinator @@ -164,6 +170,9 @@ struct SummaryView: View { .onAppear { onAppear() } + .onChange(of: paymentInfo) { _ in + paymentInfoChanged() + } .task { await monitorBlockchain() } @@ -202,7 +211,7 @@ struct SummaryView: View { VStack { Group { if payment is Lightning_kmpInboundLiquidityOutgoingPayment { - Text("Liquidity Added") + Text("Channel Resized") } else if payment is Lightning_kmpOutgoingPayment { Text("SENT") @@ -505,22 +514,29 @@ struct SummaryView: View { SummaryInfoGrid( paymentInfo: $paymentInfo, showOriginalFiatValue: $showOriginalFiatValue, - showContactView: showContactView + liquidityPayment: $liquidityPayment, + showContactView: showContactView, + switchToPayment: switchToPayment ) } @ViewBuilder func buttonList() -> some View { + let dts = dynamicTypeSize + let buttonListTruncationDetected_compact = buttonListTruncationDetection_compact[dts] ?? false + let buttonListTruncationDetected_squeezed = buttonListTruncationDetection_squeezed[dts] ?? false + let buttonListTruncationDetected_standard = buttonListTruncationDetection_standard[dts] ?? false + Group { if buttonListTruncationDetected_compact { buttonList_accessibility() } else if buttonListTruncationDetected_squeezed { - buttonList_compact() + buttonList_compact(dts) } else if buttonListTruncationDetected_standard { - buttonList_squeezed() + buttonList_squeezed(dts) } else { - buttonList_standard() + buttonList_standard(dts) } } // .confirmationDialog("Delete payment?", @@ -534,7 +550,7 @@ struct SummaryView: View { } @ViewBuilder - func buttonList_standard() -> some View { + func buttonList_standard(_ dts: DynamicTypeSize) -> some View { // We're making all the buttons the same size. // @@ -556,8 +572,8 @@ struct SummaryView: View { .read(buttonWidthReader) .read(buttonHeightReader) } wasTruncated: { - log.debug("buttonListTruncationDetected_standard = true (details)") - buttonListTruncationDetected_standard = true + log.debug("buttonListTruncationDetection_standard[\(dts)] = true (details)") + buttonListTruncationDetection_standard[dts] = true } if let buttonHeight = buttonHeight { @@ -574,8 +590,8 @@ struct SummaryView: View { .read(buttonWidthReader) .read(buttonHeightReader) } wasTruncated: { - log.debug("buttonListTruncationDetected_standard = true (edit)") - buttonListTruncationDetected_standard = true + log.debug("buttonListTruncationDetection_standard[\(dts)] = true (edit)") + buttonListTruncationDetection_standard[dts] = true } if let buttonHeight = buttonHeight { @@ -592,8 +608,8 @@ struct SummaryView: View { .read(buttonWidthReader) .read(buttonHeightReader) } wasTruncated: { - log.debug("buttonListTruncationDetected_standard = true (delete)") - buttonListTruncationDetected_standard = true + log.debug("buttonListTruncationDetection_standard[\(dts)] = true (delete)") + buttonListTruncationDetection_standard[dts] = true } } .padding(.all) @@ -602,7 +618,7 @@ struct SummaryView: View { } @ViewBuilder - func buttonList_squeezed() -> some View { + func buttonList_squeezed(_ dts: DynamicTypeSize) -> some View { // There's not enough space to make all the buttons the same size. // So we're just making the left & right buttons the same size. @@ -626,8 +642,8 @@ struct SummaryView: View { .read(buttonWidthReader) .read(buttonHeightReader) } wasTruncated: { - log.debug("buttonListTruncationDetected_squeezed = true (edit)") - buttonListTruncationDetected_squeezed = true + log.debug("buttonListTruncationDetection_squeezed[\(dts)] = true (edit)") + buttonListTruncationDetection_squeezed[dts] = true } if let buttonHeight = buttonHeight { @@ -643,8 +659,8 @@ struct SummaryView: View { } .read(buttonHeightReader) } wasTruncated: { - log.debug("buttonListTruncationDetected_squeezed = true (edit)") - buttonListTruncationDetected_squeezed = true + log.debug("buttonListTruncationDetection_squeezed[\(dts)] = true (edit)") + buttonListTruncationDetection_squeezed[dts] = true } if let buttonHeight = buttonHeight { @@ -662,8 +678,8 @@ struct SummaryView: View { .read(buttonWidthReader) .read(buttonHeightReader) } wasTruncated: { - log.debug("buttonListTruncationDetected_squeezed = true (delete)") - buttonListTruncationDetected_squeezed = true + log.debug("buttonListTruncationDetection_squeezed[\(dts)] = true (delete)") + buttonListTruncationDetection_squeezed[dts] = true } } .padding(.horizontal, 10) // allow content to be closer to edges @@ -673,7 +689,7 @@ struct SummaryView: View { } @ViewBuilder - func buttonList_compact() -> some View { + func buttonList_compact(_ dts: DynamicTypeSize) -> some View { // There's a large font being used, and possibly a small screen too. // Thus horizontal space is tight. @@ -696,8 +712,8 @@ struct SummaryView: View { } .read(buttonHeightReader) } wasTruncated: { - log.debug("buttonListTruncationDetected_compact = true (details)") - buttonListTruncationDetected_compact = true + log.debug("buttonListTruncationDetection_compact[\(dts)] = true (details)") + buttonListTruncationDetection_compact[dts] = true } if let buttonHeight = buttonHeight { @@ -713,8 +729,8 @@ struct SummaryView: View { } .read(buttonHeightReader) } wasTruncated: { - log.debug("buttonListTruncationDetected_compact = true (edit)") - buttonListTruncationDetected_compact = true + log.debug("buttonListTruncationDetection_compact[\(dts)] = true (edit)") + buttonListTruncationDetection_compact[dts] = true } if let buttonHeight = buttonHeight { @@ -730,8 +746,8 @@ struct SummaryView: View { } .read(buttonHeightReader) } wasTruncated: { - log.debug("buttonListTruncationDetected_compact = true (delete)") - buttonListTruncationDetected_compact = true + log.debug("buttonListTruncationDetection_compact[\(dts)] = true (delete)") + buttonListTruncationDetection_compact[dts] = true } } .padding(.horizontal, 4) // allow content to be closer to edges @@ -835,8 +851,10 @@ struct SummaryView: View { DetailsView( location: wrappedLocation(), paymentInfo: $paymentInfo, + liquidityPayment: $liquidityPayment, showOriginalFiatValue: $showOriginalFiatValue, - showFiatValueExplanation: $showFiatValueExplanation + showFiatValueExplanation: $showFiatValueExplanation, + switchToPayment: switchToPayment ) case .EditInfoView: @@ -936,28 +954,59 @@ struct SummaryView: View { // MARK: Tasks // -------------------------------------------------- - func updateConfirmations(_ onChainPayment: Lightning_kmpOnChainOutgoingPayment) async -> Int { - log.trace("checkConfirmations()") + func monitorBlockchain() async { - do { - let result = try await Biz.business.electrumClient.kotlin_getConfirmations(txid: onChainPayment.txId) - - let confirmations = result?.intValue ?? 0 - log.debug("checkConfirmations(): => \(confirmations)") - - self.blockchainConfirmations = confirmations - return confirmations - } catch { - log.error("checkConfirmations(): error: \(error)") - return 0 + // Architecture note: + // We need the ability to reset the View to display a completely different payment. + // This means our Task to monitor the blockchain needs to properly respond whenever + // the `paymentInfo` property is changed. + + var lastPaymentId: WalletPaymentId? = nil + + // Note: When the task is cancelled, the `values` stream returns nil, and we exit the loop + + for await paymentInfo in blockchainMonitorState.paymentInfoPublisher.values { + + if let paymentInfo, paymentInfo.id() == lastPaymentId { + log.debug("monitorBlockchain: ignoring duplicate paymentInfo") + continue + } + lastPaymentId = paymentInfo?.id() + + if let currentTask = blockchainMonitorState.currentTaskPublisher.value { + log.debug("monitorBlockchain: currentTask.cancel()") + currentTask.cancel() + } + + if let paymentInfo { + log.debug("monitorBlockchain: processing new paymentInfo") + + let newTask = Task { @MainActor in + await monitorBlockchain(paymentInfo) + } + blockchainMonitorState.currentTaskPublisher.send(newTask) + + } else { + log.debug("monitorBlockchain: paymentInfo is nil") + blockchainMonitorState.currentTaskPublisher.send(nil) + } + } + + if let currentTask = blockchainMonitorState.currentTaskPublisher.value { + log.debug("monitorBlockchain: currentTask.cancel()") + currentTask.cancel() } + + log.debug("monitorBlockchain: terminated") } - func monitorBlockchain() async { - log.trace("monitorBlockchain()") + func monitorBlockchain(_ paymentInfo: WalletPaymentInfo) async { + + let pid: String = paymentInfo.id().dbId.prefix(maxLength: 8) + log.trace("monitorBlockchain(\(pid))") guard let onChainPayment = paymentInfo.payment as? Lightning_kmpOnChainOutgoingPayment else { - log.debug("monitorBlockchain(): not an on-chain payment") + log.debug("monitorBlockchain(\(pid)): not an on-chain payment") return } @@ -966,43 +1015,82 @@ struct SummaryView: View { if elapsed > 24.hours() { // It was marked as mined more than 24 hours ago. // So there's really no need to check the exact confirmation count anymore. - log.debug("monitorBlockchain(): confirmedAt > 24.hours.ago") + log.debug("monitorBlockchain(\(pid)): confirmedAt > 24.hours.ago") self.blockchainConfirmations = 7 return } } - let confirmations = await updateConfirmations(onChainPayment) - if confirmations > 6 { - // No need to continue checking confirmation count, - // because the UI displays "6+" from this point forward. - log.debug("monitorBlockchain(): confirmations > 6") + let isDone = await updateConfirmations(onChainPayment, pid) + guard !isDone else { + log.debug("monitorBlockchain(\(pid)): done") return } + // Note: When the task is cancelled, the `values` stream returns nil, and we exit the loop + for await notification in Biz.business.electrumClient.notificationsPublisher().values { - if notification is Lightning_kmpHeaderSubscriptionResponse { - // A new block was mined ! - // Update confirmation count if needed. - let confirmations = await updateConfirmations(onChainPayment) - if confirmations > 6 { - // No need to continue checking confirmation count, - // because the UI displays "6+" from this point forward. - log.debug("monitorBlockchain(): confirmations > 6") - break - } - - } else { - log.debug("monitorBlockchain(): notification isNot HeaderSubscriptionResponse") + if !(notification is Lightning_kmpHeaderSubscriptionResponse) { + log.debug("monitorBlockchain(\(pid)): notification isNot HeaderSubscriptionResponse") + continue } - if Task.isCancelled { - log.debug("monitorBlockchain(): Task.isCancelled") - break - } else { - log.debug("monitorBlockchain(): Waiting for next electrum notification...") + // A new block was mined ! + // Update confirmation count if needed. + + let isDone = await updateConfirmations(onChainPayment, pid) + guard !isDone else { + log.debug("monitorBlockchain(\(pid)): done") + return } + + log.debug("monitorBlockchain(\(pid)): Waiting for next electrum notification...") + } + + log.debug("monitorBlockchain(\(pid)): terminated") + } + + func updateConfirmations( + _ onChainPayment: Lightning_kmpOnChainOutgoingPayment, + _ pid: String + ) async -> Bool { + + log.trace("updateConfirmations(\(pid))") + + let confirmations = await fetchConfirmations(onChainPayment, pid) + guard !Task.isCancelled else { + log.debug("updateConfirmations(\(pid)): Task.isCancelled") + return true + } + self.blockchainConfirmations = confirmations + + if confirmations > 6 { + // No need to continue checking confirmation count, + // because the UI displays "6+" from this point forward. + log.debug("updateConfirmations(\(pid)): confirmations > 6") + return true + } else { + return false + } + } + + func fetchConfirmations( + _ onChainPayment: Lightning_kmpOnChainOutgoingPayment, + _ pid: String + ) async -> Int { + + log.trace("fetchConfirmations(\(pid))") + + do { + let result = try await Biz.business.electrumClient.kotlin_getConfirmations(txid: onChainPayment.txId) + + let confirmations = result?.intValue ?? 0 + log.debug("fetchConfirmations(\(pid)): => \(confirmations)") + return confirmations + } catch { + log.error("checkConfirmations(\(pid)): error: \(error)") + return 0 } } @@ -1042,9 +1130,13 @@ struct SummaryView: View { } } } + } else { + // Not triggered in this particular case, so we need to trigger it manually. + paymentInfoChanged() } } else { + log.trace("subsequent appearance") // We are returning from the DetailsView/EditInfoView (via the NavigationController) // The payment metadata may have changed (e.g. description/notes modified). @@ -1076,6 +1168,33 @@ struct SummaryView: View { } } + func paymentInfoChanged() { + log.trace("paymentInfoChanged()") + + blockchainMonitorState.paymentInfoPublisher.send(paymentInfo) + + if let incomingPayment = paymentInfo.payment as? Lightning_kmpIncomingPayment, + let fundingTxId = incomingPayment.lightningPaymentFundingTxId + { + Task { @MainActor in + do { + let paymentsManager = Biz.business.paymentsManager + let payment = try await paymentsManager.getLiquidityPurchaseForTxId(txId: fundingTxId) + if let payment { + log.debug("liquidityPayment = \(payment.walletPaymentId().dbId)") + liquidityPayment = payment + } else { + log.debug("liquidityPayment = nil") + } + + } catch { + log.error("getLiquidityPurchaseForTxId(): error: \(error)") + } + + } + } + } + // -------------------------------------------------- // MARK: Actions // -------------------------------------------------- @@ -1109,6 +1228,20 @@ struct SummaryView: View { navigateTo(.ContactView(contact: contact)) } + func switchToPayment(_ paymentId: WalletPaymentId) { + log.trace("switchToPayment: \(paymentId.dbId)") + + Biz.business.paymentsManager.getPayment(id: paymentId, options: fetchOptions) { + (result: WalletPaymentInfo?, _) in + + if let result { + paymentInfo = result + liquidityPayment = nil + blockchainConfirmations = nil + } + } + } + func exploreTx(_ txId: Bitcoin_kmpTxId, website: BlockchainExplorer.Website) { log.trace("exploreTX()") @@ -1149,3 +1282,12 @@ struct SummaryView: View { } } } + +// -------------------------------------------------- +// MARK: - +// -------------------------------------------------- + +class BlockchainMonitorState: ObservableObject { + let paymentInfoPublisher = CurrentValueSubject(nil) + let currentTaskPublisher = CurrentValueSubject?, Never>(nil) +} diff --git a/phoenix-ios/phoenix-ios/views/inspect/WalletPaymentExtensions.swift b/phoenix-ios/phoenix-ios/views/inspect/WalletPaymentExtensions.swift index ed5837965..4578f8395 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/WalletPaymentExtensions.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/WalletPaymentExtensions.swift @@ -1,6 +1,12 @@ import Foundation import PhoenixShared +fileprivate let filename = "WalletPaymentExtensions" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif extension Lightning_kmpWalletPayment { @@ -85,10 +91,19 @@ extension Lightning_kmpWalletPayment { if let received = incomingPayment.received { let msat = received.receivedWith.map { - if let newChannel = $0 as? Lightning_kmpIncomingPayment.ReceivedWithNewChannel { + if let lightning = $0 as? Lightning_kmpIncomingPayment.ReceivedWith_LightningPayment { + if lightning.fundingFee?.fundingTxId != nil { + return 0 // should be separated into miner & service fees + } else { + return lightning.fees.msat + } + + } else if let newChannel = $0 as? Lightning_kmpIncomingPayment.ReceivedWithNewChannel { return newChannel.serviceFee.msat + } else if let spliceIn = $0 as? Lightning_kmpIncomingPayment.ReceivedWithSpliceIn { return spliceIn.serviceFee.msat + } else { return $0.fees.msat } @@ -96,8 +111,8 @@ extension Lightning_kmpWalletPayment { if msat > 0 { - let title = NSLocalizedString("Service Fees", comment: "Label in SummaryInfoGrid") - let exp = NSLocalizedString( + let title = String(localized: "Service Fees", comment: "Label in SummaryInfoGrid") + let exp = String(localized: """ In order to receive this payment, a new payment channel was opened. \ This is not always required. @@ -106,13 +121,13 @@ extension Lightning_kmpWalletPayment { ) return (msat, title, exp) - } - else if !incomingPayment.isSpliceIn { + + } else if !incomingPayment.isSpliceIn && !incomingPayment.isLightningPaymentWithFundingTxId { // I think it's nice to see "Fees: 0 sat" :) let msat = Int64(0) - let title = NSLocalizedString("Fees", comment: "Label in SummaryInfoGrid") + let title = String(localized: "Fees", comment: "Label in SummaryInfoGrid") let exp = "" return (msat, title, exp) @@ -135,27 +150,28 @@ extension Lightning_kmpWalletPayment { hops += part.route.count } - let title = NSLocalizedString("Lightning Fees", comment: "Label in SummaryInfoGrid") + let title = String(localized: "Lightning Fees", comment: "Label in SummaryInfoGrid") let exp: String if parts == 1 { if hops == 1 { - exp = NSLocalizedString( - "Lightning fees for routing the payment. Payment required 1 hop.", + exp = String( + localized: "Lightning fees for routing the payment. Payment required 1 hop.", comment: "Fees explanation" ) } else { - exp = String(format: NSLocalizedString( - "Lightning fees for routing the payment. Payment required %d hops.", - comment: "Fees explanation"), - hops + exp = String( + localized: "Lightning fees for routing the payment. Payment required \(hops) hops.", + comment: "Fees explanation" ) } } else { - exp = String(format: NSLocalizedString( - "Lightning fees for routing the payment. Payment was divided into %d parts, using %d hops.", - comment: "Fees explanation"), - parts, hops + exp = String(localized: + """ + Lightning fees for routing the payment. \ + Payment was divided into \(parts) parts, using \(hops) hops. + """, + comment: "Fees explanation" ) } @@ -175,9 +191,9 @@ extension Lightning_kmpWalletPayment { // An incomingPayment may have minerFees if a new channel was opened using dual-funding let sat = received.receivedWith.map { - if let newChannel = $0 as? Lightning_kmpIncomingPayment.ReceivedWithNewChannel { + if let newChannel = $0 as? Lightning_kmpIncomingPayment.ReceivedWith_NewChannel { return newChannel.miningFee.sat - } else if let spliceIn = $0 as? Lightning_kmpIncomingPayment.ReceivedWithSpliceIn { + } else if let spliceIn = $0 as? Lightning_kmpIncomingPayment.ReceivedWith_SpliceIn { return spliceIn.miningFee.sat } else { return Int64(0) @@ -187,9 +203,9 @@ extension Lightning_kmpWalletPayment { if sat > 0 { let msat = Utils.toMsat(sat: sat) - let title = NSLocalizedString("Miner Fees", comment: "Label in SummaryInfoGrid") - let exp = NSLocalizedString( - "Bitcoin network fees paid for on-chain transaction.", + let title = String(localized: "Miner Fees", comment: "Label in SummaryInfoGrid") + let exp = String( + localized: "Bitcoin network fees paid for on-chain transaction.", comment: "Fees explanation" ) @@ -212,9 +228,9 @@ extension Lightning_kmpWalletPayment { let sat = onChainOutgoingPayment.miningFees.sat let msat = Utils.toMsat(sat: sat) - let title = NSLocalizedString("Miner Fees", comment: "Label in SummaryInfoGrid") - let exp = NSLocalizedString( - "Bitcoin network fees paid for on-chain transaction.", + let title = String(localized: "Miner Fees", comment: "Label in SummaryInfoGrid") + let exp = String( + localized: "Bitcoin network fees paid for on-chain transaction.", comment: "Fees explanation" ) @@ -228,12 +244,12 @@ extension Lightning_kmpWalletPayment { if let il = self as? Lightning_kmpInboundLiquidityOutgoingPayment { - let sat = il._lease.fees.serviceFee + let sat = il.purchase.fees.serviceFee let msat = Utils.toMsat(sat: sat) - let title = NSLocalizedString("Service Fees", comment: "Label in SummaryInfoGrid") - let exp = NSLocalizedString( - "Fees paid for the liquidity service.", + let title = String(localized: "Service Fees", comment: "Label in SummaryInfoGrid") + let exp = String( + localized: "Fees paid for the liquidity service.", comment: "Fees explanation" ) @@ -244,9 +260,9 @@ extension Lightning_kmpWalletPayment { { let msat = outgoingPayment.fees.msat - outgoingPayment.routingFee.msat - let title = NSLocalizedString("Swap Fees", comment: "Label in SummaryInfoGrid") - let exp = NSLocalizedString( - "Includes Bitcoin network miner fees, and the fee for the Swap-Out service.", + let title = String(localized: "Swap Fees", comment: "Label in SummaryInfoGrid") + let exp = String( + localized: "Includes Bitcoin network miner fees, and the fee for the Swap-Out service.", comment: "Fees explanation" ) diff --git a/phoenix-ios/phoenix-ios/views/notifications/BizNotificationCell.swift b/phoenix-ios/phoenix-ios/views/notifications/BizNotificationCell.swift index 302cea37d..fbdfe7dae 100644 --- a/phoenix-ios/phoenix-ios/views/notifications/BizNotificationCell.swift +++ b/phoenix-ios/phoenix-ios/views/notifications/BizNotificationCell.swift @@ -140,13 +140,12 @@ struct BizNotificationCell: View { } // Group { - if reason is PhoenixShared.Notification.PaymentRejected.FeePolicyDisabled { + switch onEnum(of: reason) { + case .feePolicyDisabled(_): Text("Automated incoming liquidity is disabled in your incoming fee settings.") - - } else if reason is PhoenixShared.Notification.PaymentRejected.ChannelsInitializing { - Text("Channels initializing...") - - } else { + case .missingOffChainAmountTooLow(_): + Text("Missing off-chain amount too low.") + default: Text("Unknown reason.") } } diff --git a/phoenix-ios/phoenix-ios/views/receive/ReceiveView.swift b/phoenix-ios/phoenix-ios/views/receive/ReceiveView.swift index 35ddf79ba..9af4aff8f 100644 --- a/phoenix-ios/phoenix-ios/views/receive/ReceiveView.swift +++ b/phoenix-ios/phoenix-ios/views/receive/ReceiveView.swift @@ -33,7 +33,6 @@ struct ReceiveView: MVIView { @StateObject var toast = Toast() @Environment(\.colorScheme) var colorScheme - @Environment(\.dynamicTypeSize) var dynamicTypeSize: DynamicTypeSize @EnvironmentObject var deviceInfo: DeviceInfo @EnvironmentObject var popoverState: PopoverState diff --git a/phoenix-ios/phoenix-ios/views/send/SpliceOutProblem.swift b/phoenix-ios/phoenix-ios/views/send/ChannelFundingProblem.swift similarity index 72% rename from phoenix-ios/phoenix-ios/views/send/SpliceOutProblem.swift rename to phoenix-ios/phoenix-ios/views/send/ChannelFundingProblem.swift index bf7a04c13..2e8112cb3 100644 --- a/phoenix-ios/phoenix-ios/views/send/SpliceOutProblem.swift +++ b/phoenix-ios/phoenix-ios/views/send/ChannelFundingProblem.swift @@ -1,10 +1,10 @@ import Foundation import PhoenixShared -enum SpliceOutProblem: Error { +enum ChannelFundingProblem: Error { case insufficientFunds case spliceAlreadyInProgress - case channelNotQuiescent + case spliceAborted case sessionError case disconnected case other @@ -16,7 +16,7 @@ enum SpliceOutProblem: Error { return String(localized: "Insufficient funds") case .spliceAlreadyInProgress: return String(localized: "Splice already in progress") - case .channelNotQuiescent: + case .spliceAborted: return String(localized: "Splice has been aborted") case .sessionError: return String(localized: "Splice-out session error") @@ -28,8 +28,8 @@ enum SpliceOutProblem: Error { } static func fromResponse( - _ response: Lightning_kmpChannelCommand.CommitmentSpliceResponse? - ) -> SpliceOutProblem? { + _ response: Lightning_kmpChannelFundingResponse? + ) -> ChannelFundingProblem? { guard let response else { return .other @@ -42,11 +42,23 @@ enum SpliceOutProblem: Error { if let _ = failure.asInsufficientFunds() { return .insufficientFunds } + if let _ = failure.asInvalidSpliceOutPubKeyScript() { + return .sessionError + } if let _ = failure.asSpliceAlreadyInProgress() { return .spliceAlreadyInProgress } + if let _ = failure.asConcurrentRemoteSplice() { + return .spliceAborted + } if let _ = failure.asChannelNotQuiescent() { - return .channelNotQuiescent + return .spliceAborted + } + if let _ = failure.asInvalidChannelParameters() { + return .sessionError + } + if let _ = failure.asInvalidLiquidityAds() { + return .sessionError } if let _ = failure.asFundingFailure() { return .sessionError @@ -63,6 +75,9 @@ enum SpliceOutProblem: Error { if let _ = failure.asAbortedByPeer() { return .sessionError } + if let _ = failure.asUnexpectedMessage() { + return .sessionError + } if let _ = failure.asDisconnected() { return .disconnected } diff --git a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift index 11baded3d..a728e7ed4 100644 --- a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift +++ b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift @@ -37,7 +37,7 @@ struct ValidateView: View { @State var paymentInProgress: Bool = false @State var payOfferProblem: PayOfferProblem? = nil - @State var spliceOutProblem: SpliceOutProblem? = nil + @State var spliceOutProblem: ChannelFundingProblem? = nil @State var preTipAmountMsat: Int64? = nil @State var postTipAmountMsat: Int64? = nil @@ -1765,7 +1765,7 @@ struct ValidateView: View { self.paymentInProgress = false - if let problem = SpliceOutProblem.fromResponse(response) { + if let problem = ChannelFundingProblem.fromResponse(response) { self.spliceOutProblem = problem } else { diff --git a/phoenix-ios/phoenix-ios/views/transactions/PaymentCell.swift b/phoenix-ios/phoenix-ios/views/transactions/PaymentCell.swift index 3b849da8e..31d6d3ad0 100644 --- a/phoenix-ios/phoenix-ios/views/transactions/PaymentCell.swift +++ b/phoenix-ios/phoenix-ios/views/transactions/PaymentCell.swift @@ -166,7 +166,12 @@ struct PaymentCell : View { func paymentAmount() -> some View { let (amount, isFailure, isOutgoing) = paymentAmountInfo() - if currencyPrefs.hideAmounts { + if isLiquidityPaidInTheFuture() { + + Text(verbatim: "") + .accessibilityHidden(true) + + } else if currencyPrefs.hideAmounts { HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { @@ -241,16 +246,38 @@ struct PaymentCell : View { if let contact = fetched?.contact { if let payment = fetched?.payment, payment.isIncoming() { - return String(localized: "\(timestamp) - from \(contact.name)") + return String(localized: "\(timestamp) ∙ from \(contact.name)") } else { - return String(localized: "\(timestamp) - to \(contact.name)") + return String(localized: "\(timestamp) ∙ to \(contact.name)") } + } else if + let payment = fetched?.payment, + let liquidity = payment as? Lightning_kmpInboundLiquidityOutgoingPayment + { + let amount = Utils.formatBitcoin(sat: liquidity.purchase.amount, bitcoinUnit: .sat) + return "\(timestamp) ∙ +\(amount.string)" + } else { return timestamp } } + func line2HasExtraInfo() -> Bool { + + if fetched?.contact != nil { + // Also going to display contact name + return true + } + + if fetched?.payment is Lightning_kmpInboundLiquidityOutgoingPayment { + // Also going to display liquidity amount + return true + } + + return false + } + func stringForDate(_ completedAtDate: Date) -> String { let calendar = Calendar.current @@ -260,7 +287,7 @@ struct PaymentCell : View { let yearA = compsA.year ?? 0 let yearB = compsB.year ?? 0 - let preferShortDate = (textScaling > 100) || (fetched?.contact != nil) + let preferShortDate = (textScaling > 100) || line2HasExtraInfo() let formatter = DateFormatter() if yearA == yearB { @@ -320,6 +347,17 @@ struct PaymentCell : View { } } + func isLiquidityPaidInTheFuture() -> Bool { + + if let payment = fetched?.payment, + let liquidity = payment as? Lightning_kmpInboundLiquidityOutgoingPayment + { + return liquidity.isPaidInTheFuture() + } else { + return false + } + } + // -------------------------------------------------- // MARK: Notifications // -------------------------------------------------- diff --git a/phoenix-shared/src/androidMain/kotlin/fr/acinq/phoenix/db/androidDbFactory.kt b/phoenix-shared/src/androidMain/kotlin/fr/acinq/phoenix/db/androidDbFactory.kt index 5f9f6d960..11f862599 100644 --- a/phoenix-shared/src/androidMain/kotlin/fr/acinq/phoenix/db/androidDbFactory.kt +++ b/phoenix-shared/src/androidMain/kotlin/fr/acinq/phoenix/db/androidDbFactory.kt @@ -20,14 +20,14 @@ import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.android.AndroidSqliteDriver import fr.acinq.bitcoin.Chain import fr.acinq.phoenix.utils.PlatformContext +import fr.acinq.phoenix.utils.extensions.phoenixName actual fun createChannelsDbDriver(ctx: PlatformContext, chain: Chain, nodeIdHash: String): SqlDriver { - - return AndroidSqliteDriver(ChannelsDatabase.Schema, ctx.applicationContext, "channels-${chain.name.lowercase()}-$nodeIdHash.sqlite") + return AndroidSqliteDriver(ChannelsDatabase.Schema, ctx.applicationContext, "channels-${chain.phoenixName}-$nodeIdHash.sqlite") } actual fun createPaymentsDbDriver(ctx: PlatformContext, chain: Chain, nodeIdHash: String): SqlDriver { - return AndroidSqliteDriver(PaymentsDatabase.Schema, ctx.applicationContext, "payments-${chain.name.lowercase()}-$nodeIdHash.sqlite") + return AndroidSqliteDriver(PaymentsDatabase.Schema, ctx.applicationContext, "payments-${chain.phoenixName}-$nodeIdHash.sqlite") } actual fun createAppDbDriver(ctx: PlatformContext): SqlDriver { diff --git a/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/Notifications.sq b/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/Notifications.sq index b65844c00..525a5fb5e 100644 --- a/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/Notifications.sq +++ b/phoenix-shared/src/commonMain/appdb/fr.acinq.phoenix.db/Notifications.sq @@ -1,5 +1,3 @@ -import fr.acinq.phoenix.db.notifications.NotificationTypeVersion; - -- This table stores notifications of all kinds. -- * id => UUID of a notification -- * type_version => string tracking the type/version of a notification @@ -8,7 +6,7 @@ import fr.acinq.phoenix.db.notifications.NotificationTypeVersion; -- * read_at => when the notification was read, in millis. Read notifications are typically not shown anymore. CREATE TABLE IF NOT EXISTS notifications ( id TEXT NOT NULL PRIMARY KEY, - type_version TEXT AS NotificationTypeVersion NOT NULL, + type_version TEXT NOT NULL, data_json BLOB NOT NULL, created_at INTEGER NOT NULL, read_at INTEGER DEFAULT NULL diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/Notification.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/Notification.kt index afb6f9265..53b9ce9ec 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/Notification.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/Notification.kt @@ -63,7 +63,15 @@ sealed class Notification { override val source: LiquidityEvents.Source, ) : PaymentRejected() - data class ChannelsInitializing( + data class MissingOffChainAmountTooLow( + override val id: UUID, + override val createdAt: Long, + override val readAt: Long?, + override val amount: MilliSatoshi, + override val source: LiquidityEvents.Source, + ) : PaymentRejected() + + data class GenericError( override val id: UUID, override val createdAt: Long, override val readAt: Long?, diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqliteAppDb.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqliteAppDb.kt index 89cecfc58..fc957fde1 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqliteAppDb.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqliteAppDb.kt @@ -26,9 +26,6 @@ class SqliteAppDb(private val driver: SqlDriver) { exchange_ratesAdapter = Exchange_rates.Adapter( typeAdapter = EnumColumnAdapter() ), - notificationsAdapter = Notifications.Adapter( - type_versionAdapter = EnumColumnAdapter() - ) ) private val priceQueries = database.exchangeRatesQueries diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqlitePaymentsDb.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqlitePaymentsDb.kt index dd327bda9..3a38b2be3 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqlitePaymentsDb.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqlitePaymentsDb.kt @@ -76,9 +76,6 @@ class SqlitePaymentsDb( channel_close_outgoing_paymentsAdapter = Channel_close_outgoing_payments.Adapter( closing_info_typeAdapter = EnumColumnAdapter() ), - inbound_liquidity_outgoing_paymentsAdapter = Inbound_liquidity_outgoing_payments.Adapter( - lease_typeAdapter = EnumColumnAdapter() - ) ) internal val inQueries = IncomingQueries(database) @@ -155,6 +152,12 @@ class SqlitePaymentsDb( } } + override suspend fun getInboundLiquidityPurchase(fundingTxId: TxId): InboundLiquidityOutgoingPayment? { + return withContext(Dispatchers.Default) { + inboundLiquidityQueries.getByTxId(fundingTxId) + } + } + override suspend fun completeOutgoingPaymentOffchain( id: UUID, finalFailure: FinalFailure, diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/CloudData.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/CloudData.kt index 66966d5cc..71678b417 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/CloudData.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/CloudData.kt @@ -1,6 +1,8 @@ package fr.acinq.phoenix.db.cloud import fr.acinq.lightning.db.* +import fr.acinq.phoenix.db.cloud.payments.InboundLiquidityLegacyWrapper +import fr.acinq.phoenix.db.cloud.payments.InboundLiquidityPaymentWrapper import kotlinx.serialization.* import kotlinx.serialization.cbor.ByteString import kotlinx.serialization.cbor.Cbor @@ -68,7 +70,9 @@ data class CloudData( @SerialName("sc") val spliceCpfp: SpliceCpfpPaymentWrapper? = null, @SerialName("il") - val inboundLiquidity: InboundLiquidityPaymentWrapper? = null, + val inboundLegacyLiquidity: InboundLiquidityLegacyWrapper? = null, + @SerialName("ip") + val inboundPurchaseLiquidity: InboundLiquidityPaymentWrapper? = null, @SerialName("v") val version: Int, @ByteString @@ -81,7 +85,8 @@ data class CloudData( spliceOutgoing = null, channelClose = null, spliceCpfp = null, - inboundLiquidity = null, + inboundLegacyLiquidity = null, + inboundPurchaseLiquidity = null, version = CloudDataVersion.V0.value, padding = ByteArray(size = 0) ) @@ -92,7 +97,8 @@ data class CloudData( spliceOutgoing = if (outgoing is SpliceOutgoingPayment) SpliceOutgoingPaymentWrapper(outgoing) else null, channelClose = if (outgoing is ChannelCloseOutgoingPayment) ChannelClosePaymentWrapper(outgoing) else null, spliceCpfp = if (outgoing is SpliceCpfpOutgoingPayment) SpliceCpfpPaymentWrapper(outgoing) else null, - inboundLiquidity = if (outgoing is InboundLiquidityOutgoingPayment) InboundLiquidityPaymentWrapper(outgoing) else null, + inboundLegacyLiquidity = null, + inboundPurchaseLiquidity = if (outgoing is InboundLiquidityOutgoingPayment) InboundLiquidityPaymentWrapper(outgoing) else null, version = CloudDataVersion.V0.value, padding = ByteArray(size = 0) ) @@ -112,7 +118,8 @@ data class CloudData( spliceOutgoing != null -> spliceOutgoing.unwrap() channelClose != null -> channelClose.unwrap() spliceCpfp != null -> spliceCpfp.unwrap() - inboundLiquidity != null -> inboundLiquidity.unwrap() + inboundLegacyLiquidity != null -> inboundLegacyLiquidity.unwrap() + inboundPurchaseLiquidity != null -> inboundPurchaseLiquidity.unwrap() else -> null } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/InboundLiquidityPaymentWrapper.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/InboundLiquidityPaymentWrapper.kt index 33c56ced1..5c5419c09 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/InboundLiquidityPaymentWrapper.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/payments/InboundLiquidityPaymentWrapper.kt @@ -1,29 +1,30 @@ -package fr.acinq.phoenix.db.cloud +package fr.acinq.phoenix.db.cloud.payments import fr.acinq.bitcoin.TxId import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment import fr.acinq.lightning.utils.UUID -import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat -import fr.acinq.lightning.utils.toByteVector import fr.acinq.lightning.utils.toByteVector32 -import fr.acinq.lightning.utils.toByteVector64 import fr.acinq.lightning.wire.LiquidityAds +import fr.acinq.phoenix.db.cloud.UUIDSerializer +import fr.acinq.phoenix.db.payments.liquidityads.PurchaseData +import fr.acinq.phoenix.db.payments.liquidityads.PurchaseData.Companion.encodeAsDb import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.cbor.ByteString -@Serializable + +/** New inbound liquidity wrapper that uses the [LiquidityAds.Purchase] object. */ +@Suppress("ArrayInDataClass") @OptIn(ExperimentalSerializationApi::class) +@Serializable data class InboundLiquidityPaymentWrapper( @Serializable(with = UUIDSerializer::class) val id: UUID, - @ByteString - val channelId: ByteArray, - @ByteString - val txId: ByteArray, + @ByteString val channelId: ByteArray, + @ByteString val txId: ByteArray, val miningFeesSat: Long, - val lease: LiquidityAdsLeaseWrapper, + val purchase: LiquidityAdsPurchaseWrapper, val createdAt: Long, val confirmedAt: Long?, val lockedAt: Long?, @@ -33,7 +34,7 @@ data class InboundLiquidityPaymentWrapper( channelId = src.channelId.toByteArray(), txId = src.txId.value.toByteArray(), miningFeesSat = src.miningFees.sat, - lease = LiquidityAdsLeaseWrapper(src.lease), + purchase = LiquidityAdsPurchaseWrapper(src.purchase), createdAt = src.createdAt, confirmedAt = src.confirmedAt, lockedAt = src.lockedAt @@ -45,80 +46,76 @@ data class InboundLiquidityPaymentWrapper( channelId = this.channelId.toByteVector32(), txId = TxId(this.txId), miningFees = this.miningFeesSat.sat, - lease = this.lease.unwrap(), + purchase = this.purchase.unwrap(), + createdAt = this.createdAt, + confirmedAt = this.confirmedAt, + lockedAt = this.lockedAt, + ) + + @Serializable + data class LiquidityAdsPurchaseWrapper(@ByteString val blob: ByteArray) { + companion object { + operator fun invoke(purchase: LiquidityAds.Purchase): LiquidityAdsPurchaseWrapper { + return LiquidityAdsPurchaseWrapper(purchase.encodeAsDb()) + } + } + fun unwrap(): LiquidityAds.Purchase { + return PurchaseData.decodeAsCanonical("", blob) + } + } +} + +/** This is the legacy wrapper for inbound liquidity, that used a Lease object to represent the liquidity purchase. Used only for deserialization now. */ +@Serializable +@Suppress("ArrayInDataClass") +@OptIn(ExperimentalSerializationApi::class) +data class InboundLiquidityLegacyWrapper( + @Serializable(with = UUIDSerializer::class) + val id: UUID, + @ByteString val channelId: ByteArray, + @ByteString val txId: ByteArray, + val miningFeesSat: Long, + val lease: LiquidityAdsLeaseWrapper, + val createdAt: Long, + val confirmedAt: Long?, + val lockedAt: Long?, +) { + @Throws(Exception::class) + fun unwrap() = InboundLiquidityOutgoingPayment( + id = this.id, + channelId = this.channelId.toByteVector32(), + txId = TxId(this.txId), + miningFees = this.miningFeesSat.sat, + purchase = this.lease.unwrap(), createdAt = this.createdAt, confirmedAt = this.confirmedAt, - lockedAt = this.lockedAt + lockedAt = this.lockedAt, ) @Serializable - @OptIn(ExperimentalSerializationApi::class) data class LiquidityAdsLeaseWrapper( val amountSat: Long, val fees: LiquidityAdsLeaseFeesWrapper, - @ByteString - val sellerSig: ByteArray, - val witness: LiquidityAdsLeaseWitnessWrapper ) { - constructor(src: LiquidityAds.Lease) : this( - amountSat = src.amount.sat, - fees = LiquidityAdsLeaseFeesWrapper(src.fees), - sellerSig = src.sellerSig.toByteArray(), - witness = LiquidityAdsLeaseWitnessWrapper(src.witness) - ) - @Throws(Exception::class) - fun unwrap() = LiquidityAds.Lease( - amount = this.amountSat.sat, - fees = this.fees.unwrap(), - sellerSig = this.sellerSig.toByteVector64(), - witness = this.witness.unwrap() - ) + fun unwrap(): LiquidityAds.Purchase{ + return LiquidityAds.Purchase.Standard( + amount = this.amountSat.sat, + fees = this.fees.unwrap().let { LiquidityAds.Fees(miningFee = it.miningFee, serviceFee = it.serviceFee) }, + paymentDetails = LiquidityAds.PaymentDetails.FromChannelBalance + ) + } } @Serializable - @OptIn(ExperimentalSerializationApi::class) data class LiquidityAdsLeaseFeesWrapper( val miningFeeSat: Long, val serviceFeeSat: Long ) { - constructor(src: LiquidityAds.LeaseFees) : this( - miningFeeSat = src.miningFee.sat, - serviceFeeSat = src.serviceFee.sat - ) - @Throws(Exception::class) - fun unwrap() = LiquidityAds.LeaseFees( + fun unwrap() = LiquidityAds.Fees( miningFee = this.miningFeeSat.sat, serviceFee = this.serviceFeeSat.sat ) } - - @Serializable - @OptIn(ExperimentalSerializationApi::class) - data class LiquidityAdsLeaseWitnessWrapper( - @ByteString - val fundingScript: ByteArray, - val leaseDuration: Int, - val leaseEnd: Int, - val maxRelayFeeProportional: Int, - val maxRelayFeeBaseMsat: Long - ) { - constructor(src: LiquidityAds.LeaseWitness) : this( - fundingScript = src.fundingScript.toByteArray(), - leaseDuration = src.leaseDuration, - leaseEnd = src.leaseEnd, - maxRelayFeeProportional = src.maxRelayFeeProportional, - maxRelayFeeBaseMsat = src.maxRelayFeeBase.msat - ) - - @Throws(Exception::class) - fun unwrap() = LiquidityAds.LeaseWitness( - fundingScript = this.fundingScript.toByteVector(), - leaseDuration = this.leaseDuration, - leaseEnd = this.leaseEnd, - maxRelayFeeProportional = this.maxRelayFeeProportional, - maxRelayFeeBase = this.maxRelayFeeBaseMsat.msat - ) - } } \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/NotificationDataType.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/NotificationDataType.kt index 4aa1d1bde..0d12f85bd 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/NotificationDataType.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/NotificationDataType.kt @@ -32,26 +32,14 @@ import fr.acinq.phoenix.db.payments.DbTypesHelper import fr.acinq.phoenix.db.serializers.v1.ByteVector32Serializer import fr.acinq.phoenix.db.serializers.v1.MilliSatoshiSerializer import fr.acinq.phoenix.db.serializers.v1.SatoshiSerializer -import io.ktor.utils.io.charsets.* -import io.ktor.utils.io.core.* +import io.ktor.utils.io.charsets.Charsets +import io.ktor.utils.io.core.toByteArray import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -enum class NotificationTypeVersion { - PAYMENT_REJECTED_BY_USER_V0, - PAYMENT_REJECTED_TOO_EXPENSIVE_V0, - PAYMENT_REJECTED_OVER_ABSOLUTE_V0, - PAYMENT_REJECTED_OVER_RELATIVE_V0, - PAYMENT_REJECTED_DISABLED_V0, - PAYMENT_REJECTED_CHANNELS_INIT_V0, - - WATCH_TOWER_NOMINAL_V0, - WATCH_TOWER_UNKNOWN_V0, - WATCH_TOWER_REVOKED_FOUND_V0, -} - +@Serializable internal sealed class NotificationData { sealed class PaymentRejected : NotificationData() { sealed class OverAbsoluteFee : PaymentRejected() { @@ -79,16 +67,21 @@ internal sealed class NotificationData { data class V0(@Serializable val amount: MilliSatoshi, val source: LiquidityEvents.Source) : Disabled() } - sealed class ChannelsInitializing : PaymentRejected() { + sealed class MissingOffchainAmountTooLow : PaymentRejected() { + @Serializable + data class V0(@Serializable val amount: MilliSatoshi, val source: LiquidityEvents.Source) : MissingOffchainAmountTooLow() + } + + sealed class GenericError : PaymentRejected() { @Serializable - data class V0(@Serializable val amount: MilliSatoshi, val source: LiquidityEvents.Source) : ChannelsInitializing() + data class V0(@Serializable val amount: MilliSatoshi, val source: LiquidityEvents.Source) : GenericError() } } sealed class WatchTowerOutcome: NotificationData() { sealed class Unknown : WatchTowerOutcome() { @Serializable - object V0: Unknown() + data object V0: Unknown() } sealed class Nominal : WatchTowerOutcome() { @Serializable @@ -101,37 +94,25 @@ internal sealed class NotificationData { } companion object { - fun deserialize(typeVersion: NotificationTypeVersion, blob: ByteArray): NotificationData? = try { - DbTypesHelper.decodeBlob(blob) { json, format -> - when (typeVersion) { - NotificationTypeVersion.PAYMENT_REJECTED_OVER_ABSOLUTE_V0 -> format.decodeFromString(json) - NotificationTypeVersion.PAYMENT_REJECTED_OVER_RELATIVE_V0 -> format.decodeFromString(json) - NotificationTypeVersion.PAYMENT_REJECTED_DISABLED_V0 -> format.decodeFromString(json) - NotificationTypeVersion.PAYMENT_REJECTED_CHANNELS_INIT_V0 -> format.decodeFromString(json) - NotificationTypeVersion.WATCH_TOWER_NOMINAL_V0 -> format.decodeFromString(json) - NotificationTypeVersion.WATCH_TOWER_UNKNOWN_V0 -> format.decodeFromString(json) - NotificationTypeVersion.WATCH_TOWER_REVOKED_FOUND_V0 -> format.decodeFromString(json) - - // obsolete types - NotificationTypeVersion.PAYMENT_REJECTED_BY_USER_V0, NotificationTypeVersion.PAYMENT_REJECTED_TOO_EXPENSIVE_V0 -> { - throw UnsupportedOperationException() - } - } - } + fun decode(blob: ByteArray): NotificationData? = try { + DbTypesHelper.decodeBlob(blob) { json, format -> format.decodeFromString(json) } } catch (e: Exception) { // notifications are not critical data, can be ignored if malformed null } + + fun Notification.encodeAsDb(): ByteArray = Json.encodeToString(this.asDb()).toByteArray(Charsets.UTF_8) + + private fun Notification.asDb(): NotificationData = when (this) { + is Notification.OverAbsoluteFee -> PaymentRejected.OverAbsoluteFee.V0(amount, source, fee, maxAbsoluteFee) + is Notification.OverRelativeFee -> PaymentRejected.OverRelativeFee.V0(amount, source, fee, maxRelativeFeeBasisPoints) + is Notification.FeePolicyDisabled -> PaymentRejected.Disabled.V0(amount, source) + is Notification.MissingOffChainAmountTooLow -> PaymentRejected.MissingOffchainAmountTooLow.V0(amount, source) + is Notification.GenericError -> PaymentRejected.GenericError.V0(amount, source) + is fr.acinq.phoenix.data.WatchTowerOutcome.Nominal -> WatchTowerOutcome.Nominal.V0(channelsWatchedCount) + is fr.acinq.phoenix.data.WatchTowerOutcome.RevokedFound -> WatchTowerOutcome.RevokedFound.V0(channels) + is fr.acinq.phoenix.data.WatchTowerOutcome.Unknown -> WatchTowerOutcome.Unknown.V0 + } } } - -internal fun Notification.mapToDb(): Pair = when (this) { - is Notification.OverAbsoluteFee -> NotificationTypeVersion.PAYMENT_REJECTED_OVER_ABSOLUTE_V0 to Json.encodeToString(NotificationData.PaymentRejected.OverAbsoluteFee.V0(amount, source, fee, maxAbsoluteFee)).toByteArray(Charsets.UTF_8) - is Notification.OverRelativeFee -> NotificationTypeVersion.PAYMENT_REJECTED_OVER_RELATIVE_V0 to Json.encodeToString(NotificationData.PaymentRejected.OverRelativeFee.V0(amount, source, fee, maxRelativeFeeBasisPoints)).toByteArray(Charsets.UTF_8) - is Notification.FeePolicyDisabled -> NotificationTypeVersion.PAYMENT_REJECTED_DISABLED_V0 to Json.encodeToString(NotificationData.PaymentRejected.Disabled.V0(amount, source)).toByteArray(Charsets.UTF_8) - is Notification.ChannelsInitializing -> NotificationTypeVersion.PAYMENT_REJECTED_CHANNELS_INIT_V0 to Json.encodeToString(NotificationData.PaymentRejected.ChannelsInitializing.V0(amount, source)).toByteArray(Charsets.UTF_8) - is WatchTowerOutcome.Nominal -> NotificationTypeVersion.WATCH_TOWER_NOMINAL_V0 to Json.encodeToString(NotificationData.WatchTowerOutcome.Nominal.V0(channelsWatchedCount)).toByteArray(Charsets.UTF_8) - is WatchTowerOutcome.RevokedFound -> NotificationTypeVersion.WATCH_TOWER_REVOKED_FOUND_V0 to Json.encodeToString(NotificationData.WatchTowerOutcome.RevokedFound.V0(channels)).toByteArray(Charsets.UTF_8) - is WatchTowerOutcome.Unknown -> NotificationTypeVersion.WATCH_TOWER_UNKNOWN_V0 to Json.encodeToString(NotificationData.WatchTowerOutcome.Unknown.V0).toByteArray(Charsets.UTF_8) -} \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/NotificationsQueries.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/NotificationsQueries.kt index c82fab40c..6100e2cb3 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/NotificationsQueries.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/notifications/NotificationsQueries.kt @@ -22,7 +22,9 @@ import fr.acinq.lightning.LiquidityEvents import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.phoenix.data.Notification +import fr.acinq.phoenix.data.WatchTowerOutcome import fr.acinq.phoenix.db.AppDatabase +import fr.acinq.phoenix.db.notifications.NotificationData.Companion.encodeAsDb import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow @@ -33,16 +35,24 @@ internal class NotificationsQueries(val database: AppDatabase) { fun get(id: UUID): Notification? { return queries.get(id.toString()).executeAsOneOrNull()?.let { row -> - mapToNotification(row.id, row.type_version, row.data_json, row.created_at, row.read_at) + mapToNotification(row.id, row.data_json, row.created_at, row.read_at) } } fun save(notification: Notification) { - val (typeVersion, blob) = notification.mapToDb() queries.insert( id = notification.id.toString(), - type_version = typeVersion, - data_json = blob, + type_version = when (notification) { + is Notification.OverAbsoluteFee -> "PAYMENT_REJECTED_OVER_ABSOLUTE_FEE" + is Notification.OverRelativeFee -> "PAYMENT_REJECTED_OVER_RELATIVE_FEE" + is Notification.FeePolicyDisabled -> "PAYMENT_REJECTED_POLICY_DISABLED" + is Notification.MissingOffChainAmountTooLow -> "PAYMENT_REJECTED_OFFCHAIN_AMOUNT_TOO_LOW" + is Notification.GenericError -> "PAYMENT_REJECTED_GENERIC_ERROR" + is WatchTowerOutcome.Nominal -> "WATCH_TOWER_NOMINAL" + is WatchTowerOutcome.RevokedFound -> "WATCH_TOWER_REVOKED" + is WatchTowerOutcome.Unknown -> "WATCH_TOWER_UNKNOWN" + }, + data_json = notification.encodeAsDb(), created_at = currentTimestampMillis() ) } @@ -68,7 +78,7 @@ internal class NotificationsQueries(val database: AppDatabase) { return queries.listUnread().asFlow().mapToList(Dispatchers.IO).map { val notifs = it.mapNotNull { row -> val ids = row.grouped_ids.split(";").map { UUID.fromString(it) }.toSet() - val notif = mapToNotification(row.id, row.type_version, row.data_json, row.max ?: 0, null) + val notif = mapToNotification(row.id, row.data_json, row.max ?: 0, null) if (notif != null) { ids to notif } else { @@ -101,53 +111,51 @@ internal class NotificationsQueries(val database: AppDatabase) { /** Map columns to a [Notification] object. If the [data_json] column is unreadable, return null. */ internal fun mapToNotification( id: String, - type_version: NotificationTypeVersion, data_json: ByteArray, created_at: Long, read_at: Long?, ): Notification? { - return when (val data = NotificationData.deserialize(type_version, data_json)) { - is NotificationData.PaymentRejected.OverAbsoluteFee.V0 -> { - Notification.OverAbsoluteFee( - id = UUID.fromString(id), - createdAt = created_at, - readAt = read_at, - amount = data.amount, - source = data.source, - fee = data.fee, - maxAbsoluteFee = data.maxAbsoluteFee - ) - } - is NotificationData.PaymentRejected.OverRelativeFee.V0 -> { - Notification.OverRelativeFee( - id = UUID.fromString(id), - createdAt = created_at, - readAt = read_at, - amount = data.amount, - source = data.source, - fee = data.fee, - maxRelativeFeeBasisPoints = data.maxRelativeFeeBasisPoints - ) - } - is NotificationData.PaymentRejected.Disabled.V0 -> { - Notification.FeePolicyDisabled( - id = UUID.fromString(id), - createdAt = created_at, - readAt = read_at, - amount = data.amount, - source = data.source, - ) - } - is NotificationData.PaymentRejected.ChannelsInitializing.V0 -> { - Notification.ChannelsInitializing( - id = UUID.fromString(id), - createdAt = created_at, - readAt = read_at, - amount = data.amount, - source = data.source, - ) - } - is NotificationData.WatchTowerOutcome -> null + return when (val data = NotificationData.decode(data_json)) { + is NotificationData.PaymentRejected.OverAbsoluteFee.V0 -> Notification.OverAbsoluteFee( + id = UUID.fromString(id), + createdAt = created_at, + readAt = read_at, + amount = data.amount, + source = data.source, + fee = data.fee, + maxAbsoluteFee = data.maxAbsoluteFee + ) + is NotificationData.PaymentRejected.OverRelativeFee.V0 -> Notification.OverRelativeFee( + id = UUID.fromString(id), + createdAt = created_at, + readAt = read_at, + amount = data.amount, + source = data.source, + fee = data.fee, + maxRelativeFeeBasisPoints = data.maxRelativeFeeBasisPoints + ) + is NotificationData.PaymentRejected.Disabled.V0 -> Notification.FeePolicyDisabled( + id = UUID.fromString(id), + createdAt = created_at, + readAt = read_at, + amount = data.amount, + source = data.source, + ) + is NotificationData.PaymentRejected.MissingOffchainAmountTooLow.V0 -> Notification.MissingOffChainAmountTooLow( + id = UUID.fromString(id), + createdAt = created_at, + readAt = read_at, + amount = data.amount, + source = data.source, + ) + is NotificationData.PaymentRejected.GenericError.V0 -> Notification.GenericError( + id = UUID.fromString(id), + createdAt = created_at, + readAt = read_at, + amount = data.amount, + source = data.source, + ) + is NotificationData.WatchTowerOutcome -> null // ignored null -> null } } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/DbTypesHelper.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/DbTypesHelper.kt index 98730f1bf..25f5c4a61 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/DbTypesHelper.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/DbTypesHelper.kt @@ -30,6 +30,7 @@ object DbTypesHelper { val module = SerializersModule { polymorphic(IncomingReceivedWithData.Part::class) { subclass(IncomingReceivedWithData.Part.Htlc.V0::class) + subclass(IncomingReceivedWithData.Part.Htlc.V1::class) @Suppress("DEPRECATION") subclass(IncomingReceivedWithData.Part.NewChannel.V0::class) subclass(IncomingReceivedWithData.Part.NewChannel.V1::class) diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/InboundLiquidityLeaseType.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/InboundLiquidityLeaseType.kt deleted file mode 100644 index c45249b8b..000000000 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/InboundLiquidityLeaseType.kt +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright 2023 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file:UseSerializers( - ByteVectorSerializer::class, - ByteVector32Serializer::class, - ByteVector64Serializer::class, - SatoshiSerializer::class, - MilliSatoshiSerializer::class -) - -package fr.acinq.phoenix.db.payments - -import fr.acinq.bitcoin.ByteVector -import fr.acinq.bitcoin.ByteVector64 -import fr.acinq.bitcoin.Satoshi -import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment -import fr.acinq.lightning.wire.LiquidityAds -import fr.acinq.phoenix.db.serializers.v1.ByteVector32Serializer -import fr.acinq.phoenix.db.serializers.v1.ByteVector64Serializer -import fr.acinq.phoenix.db.serializers.v1.ByteVectorSerializer -import fr.acinq.phoenix.db.serializers.v1.MilliSatoshiSerializer -import fr.acinq.phoenix.db.serializers.v1.SatoshiSerializer -import io.ktor.utils.io.charsets.Charsets -import io.ktor.utils.io.core.toByteArray -import kotlinx.serialization.Serializable -import kotlinx.serialization.UseSerializers -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json - -enum class InboundLiquidityLeaseTypeVersion { - LEASE_V0, -} - -sealed class InboundLiquidityLeaseData { - - @Serializable - data class V0( - val amount: Satoshi, - val miningFees: Satoshi, - val serviceFee: Satoshi, - val sellerSig: ByteVector64, - val witnessFundingScript: ByteVector, - val witnessLeaseDuration: Int, - val witnessLeaseEnd: Int, - val witnessMaxRelayFeeProportional: Int, - val witnessMaxRelayFeeBase: MilliSatoshi - ) : InboundLiquidityLeaseData() - - companion object { - /** Deserializes a json-encoded blob containing data for an [LiquidityAds.Lease] object. */ - fun deserialize( - typeVersion: InboundLiquidityLeaseTypeVersion, - blob: ByteArray, - ): LiquidityAds.Lease = DbTypesHelper.decodeBlob(blob) { json, format -> - when (typeVersion) { - InboundLiquidityLeaseTypeVersion.LEASE_V0 -> format.decodeFromString(json).let { - LiquidityAds.Lease( - amount = it.amount, - fees = LiquidityAds.LeaseFees(miningFee = it.miningFees, serviceFee = it.serviceFee), - sellerSig = it.sellerSig, - witness = LiquidityAds.LeaseWitness( - fundingScript = it.witnessFundingScript, - leaseDuration = it.witnessLeaseDuration, - leaseEnd = it.witnessLeaseEnd, - maxRelayFeeProportional = it.witnessMaxRelayFeeProportional, - maxRelayFeeBase = it.witnessMaxRelayFeeBase, - ) - ) - } - } - } - } -} - -fun InboundLiquidityOutgoingPayment.mapLeaseToDb() = InboundLiquidityLeaseTypeVersion.LEASE_V0 to - InboundLiquidityLeaseData.V0( - amount = lease.amount, - miningFees = lease.fees.miningFee, - serviceFee = lease.fees.serviceFee, - sellerSig = lease.sellerSig, - witnessFundingScript = lease.witness.fundingScript, - witnessLeaseDuration = lease.witness.leaseDuration, - witnessLeaseEnd = lease.witness.leaseEnd, - witnessMaxRelayFeeProportional = lease.witness.maxRelayFeeProportional, - witnessMaxRelayFeeBase = lease.witness.maxRelayFeeBase, - ).let { - Json.encodeToString(it).toByteArray(Charsets.UTF_8) - } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/InboundLiquidityQueries.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/InboundLiquidityQueries.kt index 869e8fe09..618376b5f 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/InboundLiquidityQueries.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/InboundLiquidityQueries.kt @@ -21,23 +21,34 @@ import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.sat import fr.acinq.lightning.utils.toByteVector32 +import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.db.PaymentsDatabase import fr.acinq.phoenix.db.didSaveWalletPayment +import fr.acinq.phoenix.db.payments.liquidityads.PurchaseData +import fr.acinq.phoenix.db.payments.liquidityads.PurchaseData.Companion.encodeAsDb class InboundLiquidityQueries(val database: PaymentsDatabase) { private val queries = database.inboundLiquidityOutgoingQueries fun add(payment: InboundLiquidityOutgoingPayment) { database.transaction { - val (leaseType, leaseData) = payment.mapLeaseToDb() queries.insert( id = payment.id.toString(), mining_fees_sat = payment.miningFees.sat, channel_id = payment.channelId.toByteArray(), tx_id = payment.txId.value.toByteArray(), - lease_type = leaseType, - lease_blob = leaseData, + lease_type = when (payment.purchase) { + is LiquidityAds.Purchase.Standard -> "STANDARD" + is LiquidityAds.Purchase.WithFeeCredit -> "WITH_FEE_CREDIT" + }, + lease_blob = payment.purchase.encodeAsDb(), + payment_details_type = when (payment.purchase.paymentDetails) { + is LiquidityAds.PaymentDetails.FromChannelBalance -> "FROM_CHANNEL_BALANCE" + is LiquidityAds.PaymentDetails.FromFutureHtlc -> "FROM_FUTURE_HTLC" + is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> "FROM_FUTURE_HTLC_WITH_PREIMAGE" + is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> "FROM_CHANNEL_BALANCE_FOR_FUTURE_HTLC" + }, created_at = payment.createdAt, confirmed_at = payment.confirmedAt, locked_at = payment.lockedAt, @@ -50,6 +61,11 @@ class InboundLiquidityQueries(val database: PaymentsDatabase) { .executeAsOneOrNull() } + fun getByTxId(txId: TxId): InboundLiquidityOutgoingPayment? { + return queries.getByTxId(tx_id = txId.value.toByteArray(), mapper = Companion::mapPayment) + .executeAsOneOrNull() + } + fun setConfirmed(id: UUID, confirmedAt: Long) { database.transaction { queries.setConfirmed(confirmed_at = confirmedAt, id = id.toString()) @@ -70,7 +86,7 @@ class InboundLiquidityQueries(val database: PaymentsDatabase) { mining_fees_sat: Long, channel_id: ByteArray, tx_id: ByteArray, - lease_type: InboundLiquidityLeaseTypeVersion, + lease_type: String, lease_blob: ByteArray, created_at: Long, confirmed_at: Long?, @@ -81,7 +97,7 @@ class InboundLiquidityQueries(val database: PaymentsDatabase) { miningFees = mining_fees_sat.sat, channelId = channel_id.toByteVector32(), txId = TxId(tx_id), - lease = InboundLiquidityLeaseData.deserialize(lease_type, lease_blob), + purchase = PurchaseData.decodeAsCanonical(lease_type, lease_blob), createdAt = created_at, confirmedAt = confirmed_at, lockedAt = locked_at diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/IncomingReceivedWithType.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/IncomingReceivedWithType.kt index 6623e3df7..2ad4c3e14 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/IncomingReceivedWithType.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/IncomingReceivedWithType.kt @@ -34,6 +34,9 @@ import fr.acinq.phoenix.db.serializers.v1.ByteVector32Serializer import fr.acinq.phoenix.db.serializers.v1.MilliSatoshiSerializer import fr.acinq.phoenix.db.serializers.v1.UUIDSerializer import fr.acinq.lightning.utils.sat +import fr.acinq.phoenix.db.payments.liquidityads.FundingFeeData +import fr.acinq.phoenix.db.payments.liquidityads.FundingFeeData.Companion.asCanonical +import fr.acinq.phoenix.db.payments.liquidityads.FundingFeeData.Companion.asDb import fr.acinq.phoenix.db.serializers.v1.SatoshiSerializer import io.ktor.utils.io.charsets.* import io.ktor.utils.io.core.* @@ -80,12 +83,21 @@ sealed class IncomingReceivedWithData { @Serializable sealed class Part : IncomingReceivedWithData() { sealed class Htlc : Part() { + @Deprecated("Replaced by [Htlc.V1], which supports the liquidity ads funding fee") @Serializable data class V0( @Serializable val amount: MilliSatoshi, @Serializable val channelId: ByteVector32, val htlcId: Long ) : Htlc() + + @Serializable + data class V1( + val amountReceived: MilliSatoshi, + val channelId: ByteVector32, + val htlcId: Long, + val fundingFee: FundingFeeData?, + ) : Htlc() } sealed class NewChannel : Part() { @@ -131,6 +143,13 @@ sealed class IncomingReceivedWithData { @Serializable val lockedAt: Long?, ) : SpliceIn() } + + sealed class FeeCredit : Part() { + @Serializable + data class V0( + val amount: MilliSatoshi + ) : FeeCredit() + } } companion object { @@ -152,11 +171,11 @@ sealed class IncomingReceivedWithData { @Suppress("DEPRECATION") when (typeVersion) { IncomingReceivedWithTypeVersion.LIGHTNING_PAYMENT_V0 -> listOf( - IncomingPayment.ReceivedWith.LightningPayment(amount ?: 0.msat, ByteVector32.Zeroes, 0L) + IncomingPayment.ReceivedWith.LightningPayment(amount ?: 0.msat, ByteVector32.Zeroes, 0L, null) ) IncomingReceivedWithTypeVersion.NEW_CHANNEL_V0 -> listOf(format.decodeFromString(json).let { IncomingPayment.ReceivedWith.NewChannel( - amount = amount ?: 0.msat, + amountReceived = amount ?: 0.msat, serviceFee = it.fees, miningFee = 0.sat, channelId = it.channelId ?: ByteVector32.Zeroes, @@ -165,12 +184,13 @@ sealed class IncomingReceivedWithData { lockedAt = 0, ) }) - IncomingReceivedWithTypeVersion.MULTIPARTS_V0 -> DbTypesHelper.polymorphicFormat.decodeFromString(SetSerializer(PolymorphicSerializer(Part::class)), json).map { + IncomingReceivedWithTypeVersion.MULTIPARTS_V0 -> DbTypesHelper.polymorphicFormat.decodeFromString(SetSerializer(PolymorphicSerializer(Part::class)), json).mapNotNull { when (it) { - is Part.Htlc.V0 -> IncomingPayment.ReceivedWith.LightningPayment(it.amount, it.channelId, it.htlcId) + is Part.Htlc.V0 -> IncomingPayment.ReceivedWith.LightningPayment(it.amount, it.channelId, it.htlcId, null) + is Part.Htlc.V1 -> IncomingPayment.ReceivedWith.LightningPayment(it.amountReceived, it.channelId, it.htlcId, it.fundingFee?.asCanonical()) is Part.NewChannel.V0 -> if (originTypeVersion == IncomingOriginTypeVersion.SWAPIN_V0) { IncomingPayment.ReceivedWith.NewChannel( - amount = it.amount, + amountReceived = it.amount, serviceFee = it.fees, miningFee = 0.sat, channelId = it.channelId ?: ByteVector32.Zeroes, @@ -180,7 +200,7 @@ sealed class IncomingReceivedWithData { ) } else { IncomingPayment.ReceivedWith.NewChannel( - amount = it.amount - it.fees, + amountReceived = it.amount - it.fees, serviceFee = it.fees, miningFee = 0.sat, channelId = it.channelId ?: ByteVector32.Zeroes, @@ -191,12 +211,23 @@ sealed class IncomingReceivedWithData { } else -> null // does not apply, MULTIPARTS_V0 only uses V0 parts } - }.filterNotNull() // null elements are discarded! + } IncomingReceivedWithTypeVersion.MULTIPARTS_V1 -> DbTypesHelper.polymorphicFormat.decodeFromString(SetSerializer(PolymorphicSerializer(Part::class)), json).map { when (it) { - is Part.Htlc.V0 -> IncomingPayment.ReceivedWith.LightningPayment(it.amount, it.channelId, it.htlcId) + is Part.Htlc.V0 -> IncomingPayment.ReceivedWith.LightningPayment( + amountReceived = it.amount, + channelId = it.channelId, + htlcId = it.htlcId, + fundingFee = null + ) + is Part.Htlc.V1 -> IncomingPayment.ReceivedWith.LightningPayment( + amountReceived = it.amountReceived, + channelId = it.channelId, + htlcId = it.htlcId, + fundingFee = it.fundingFee?.asCanonical() + ) is Part.NewChannel.V0 -> IncomingPayment.ReceivedWith.NewChannel( - amount = it.amount, + amountReceived = it.amount, serviceFee = it.fees, miningFee = 0.sat, channelId = it.channelId ?: ByteVector32.Zeroes, @@ -205,7 +236,7 @@ sealed class IncomingReceivedWithData { lockedAt = 0, ) is Part.NewChannel.V1 -> IncomingPayment.ReceivedWith.NewChannel( - amount = it.amount, + amountReceived = it.amount, serviceFee = it.fees, miningFee = 0.sat, channelId = it.channelId ?: ByteVector32.Zeroes, @@ -214,7 +245,7 @@ sealed class IncomingReceivedWithData { lockedAt = 0, ) is Part.NewChannel.V2 -> IncomingPayment.ReceivedWith.NewChannel( - amount = it.amount, + amountReceived = it.amount, serviceFee = it.serviceFee, miningFee = it.miningFee, channelId = it.channelId, @@ -223,7 +254,7 @@ sealed class IncomingReceivedWithData { lockedAt = it.lockedAt, ) is Part.SpliceIn.V0 -> IncomingPayment.ReceivedWith.SpliceIn( - amount = it.amount, + amountReceived = it.amount, serviceFee = it.serviceFee, miningFee = it.miningFee, channelId = it.channelId, @@ -231,8 +262,11 @@ sealed class IncomingReceivedWithData { confirmedAt = it.confirmedAt, lockedAt = it.lockedAt, ) + is Part.FeeCredit.V0 -> IncomingPayment.ReceivedWith.AddedToFeeCredit( + amountReceived = it.amount + ) } - }.filterNotNull() // null elements are discarded! + } } } } @@ -241,9 +275,14 @@ sealed class IncomingReceivedWithData { /** Only serialize received_with into the [IncomingReceivedWithTypeVersion.MULTIPARTS_V1] type. */ fun List.mapToDb(): Pair? = map { when (it) { - is IncomingPayment.ReceivedWith.LightningPayment -> IncomingReceivedWithData.Part.Htlc.V0(it.amount, it.channelId, it.htlcId) + is IncomingPayment.ReceivedWith.LightningPayment -> IncomingReceivedWithData.Part.Htlc.V1( + amountReceived = it.amountReceived, + channelId = it.channelId, + htlcId = it.htlcId, + fundingFee = it.fundingFee?.asDb() + ) is IncomingPayment.ReceivedWith.NewChannel -> IncomingReceivedWithData.Part.NewChannel.V2( - amount = it.amount, + amount = it.amountReceived, serviceFee = it.serviceFee, miningFee = it.miningFee, channelId = it.channelId, @@ -252,7 +291,7 @@ fun List.mapToDb(): Pair IncomingReceivedWithData.Part.SpliceIn.V0( - amount = it.amount, + amount = it.amountReceived, serviceFee = it.serviceFee, miningFee = it.miningFee, channelId = it.channelId, @@ -260,6 +299,9 @@ fun List.mapToDb(): Pair IncomingReceivedWithData.Part.FeeCredit.V0( + amount = it.amountReceived + ) } }.takeIf { it.isNotEmpty() }?.toSet()?.let { IncomingReceivedWithTypeVersion.MULTIPARTS_V1 to DbTypesHelper.polymorphicFormat.encodeToString( diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/FundingFeeData.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/FundingFeeData.kt new file mode 100644 index 000000000..626853ed6 --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/FundingFeeData.kt @@ -0,0 +1,28 @@ +@file:UseSerializers( + MilliSatoshiSerializer::class, + TxIdSerializer::class, +) + +package fr.acinq.phoenix.db.payments.liquidityads + +import fr.acinq.bitcoin.TxId +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.phoenix.db.serializers.v1.MilliSatoshiSerializer +import fr.acinq.phoenix.db.serializers.v1.TxIdSerializer +import fr.acinq.lightning.wire.LiquidityAds +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers + +@Serializable +sealed class FundingFeeData { + + @Serializable + data class V0(val amount: MilliSatoshi, val fundingTxId: TxId) : FundingFeeData() + + companion object { + fun FundingFeeData.asCanonical(): LiquidityAds.FundingFee = when (this) { + is V0 -> LiquidityAds.FundingFee(amount = amount, fundingTxId = fundingTxId) + } + fun LiquidityAds.FundingFee.asDb(): FundingFeeData = V0(amount = amount, fundingTxId = fundingTxId) + } +} \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/LegacyLeaseData.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/LegacyLeaseData.kt new file mode 100644 index 000000000..f8602d565 --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/LegacyLeaseData.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2023 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:UseSerializers( + ByteVectorSerializer::class, + ByteVector32Serializer::class, + ByteVector64Serializer::class, + SatoshiSerializer::class, + MilliSatoshiSerializer::class +) + +package fr.acinq.phoenix.db.payments.liquidityads + +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.ByteVector64 +import fr.acinq.bitcoin.Satoshi +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.wire.LiquidityAds +import fr.acinq.phoenix.db.serializers.v1.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers + +enum class InboundLiquidityLeaseType { + @Deprecated("obsolete with the new on-the-fly channel funding that replaces lease -> purchase") + LEASE_V0 +} + +@Suppress("DEPRECATION_WARNING") +@Deprecated("obsolete with the new on-the-fly channel funding that replaces lease with purchase") +@Serializable +data class LeaseV0( + val amount: Satoshi, + val miningFees: Satoshi, + val serviceFee: Satoshi, + val sellerSig: ByteVector64, + val witnessFundingScript: ByteVector, + val witnessLeaseDuration: Int, + val witnessLeaseEnd: Int, + val witnessMaxRelayFeeProportional: Int, + val witnessMaxRelayFeeBase: MilliSatoshi +) { + /** Maps a legacy lease data into the modern [LiquidityAds.Purchase] object using fake payment details data. */ + fun toLiquidityAdsPurchase(): LiquidityAds.Purchase = LiquidityAds.Purchase.Standard( + amount = amount, + fees = LiquidityAds.Fees(miningFee = miningFees, serviceFee = serviceFee), + paymentDetails = LiquidityAds.PaymentDetails.FromChannelBalance + ) +} \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/PaymentDetailsData.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/PaymentDetailsData.kt new file mode 100644 index 000000000..7431c9c9b --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/PaymentDetailsData.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:UseSerializers( + ByteVector32Serializer::class, +) + +package fr.acinq.phoenix.db.payments.liquidityads + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.phoenix.db.serializers.v1.ByteVector32Serializer +import fr.acinq.lightning.wire.LiquidityAds +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers + + +@Serializable +sealed class PaymentDetailsData { + sealed class ChannelBalance : PaymentDetailsData() { + @Serializable + data object V0 : ChannelBalance() + } + + sealed class FutureHtlc : PaymentDetailsData() { + @Serializable + data class V0(val paymentHashes: List) : FutureHtlc() + } + + sealed class FutureHtlcWithPreimage : PaymentDetailsData() { + @Serializable + data class V0(val preimages: List) : FutureHtlcWithPreimage() + } + + sealed class ChannelBalanceForFutureHtlc : PaymentDetailsData() { + @Serializable + data class V0(val paymentHashes: List) : ChannelBalanceForFutureHtlc() + } + + companion object { + fun PaymentDetailsData.asCanonical(): LiquidityAds.PaymentDetails = when (this) { + is ChannelBalance.V0 -> LiquidityAds.PaymentDetails.FromChannelBalance + is FutureHtlc.V0 -> LiquidityAds.PaymentDetails.FromFutureHtlc(this.paymentHashes) + is FutureHtlcWithPreimage.V0 -> LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage(this.preimages) + is ChannelBalanceForFutureHtlc.V0 -> LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(this.paymentHashes) + } + + fun LiquidityAds.PaymentDetails.asDb(): PaymentDetailsData = when (this) { + is LiquidityAds.PaymentDetails.FromChannelBalance -> ChannelBalance.V0 + is LiquidityAds.PaymentDetails.FromFutureHtlc -> FutureHtlc.V0(this.paymentHashes) + is LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage -> FutureHtlcWithPreimage.V0(this.preimages) + is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> ChannelBalanceForFutureHtlc.V0(this.paymentHashes) + } + } +} \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/PurchaseData.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/PurchaseData.kt new file mode 100644 index 000000000..336c950d5 --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/liquidityads/PurchaseData.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:UseSerializers( + SatoshiSerializer::class, + MilliSatoshiSerializer::class +) + +package fr.acinq.phoenix.db.payments.liquidityads + +import fr.acinq.bitcoin.Satoshi +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.phoenix.db.payments.liquidityads.PaymentDetailsData.Companion.asCanonical +import fr.acinq.phoenix.db.payments.liquidityads.PaymentDetailsData.Companion.asDb +import fr.acinq.phoenix.db.serializers.v1.SatoshiSerializer +import fr.acinq.phoenix.db.serializers.v1.MilliSatoshiSerializer +import fr.acinq.lightning.wire.LiquidityAds +import fr.acinq.phoenix.db.payments.DbTypesHelper +import io.ktor.utils.io.charsets.Charsets +import io.ktor.utils.io.core.toByteArray +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Serializable +sealed class PurchaseData { + sealed class Standard : PurchaseData() { + @Serializable + data class V0( + val amount: Satoshi, + val miningFees: Satoshi, + val serviceFee: Satoshi, + val paymentDetails: PaymentDetailsData, + ) : Standard() + } + sealed class WithFeeCredit : PurchaseData() { + @Serializable + data class V0( + val amount: Satoshi, + val miningFees: Satoshi, + val serviceFee: Satoshi, + val feeCreditUsed: MilliSatoshi, + val paymentDetails: PaymentDetailsData, + ) : WithFeeCredit() + } + + companion object { + private fun PurchaseData.asCanonical(): LiquidityAds.Purchase = when (this) { + is Standard.V0 -> LiquidityAds.Purchase.Standard( + amount = amount, + fees = LiquidityAds.Fees(miningFee = miningFees, serviceFee = serviceFee), + paymentDetails = paymentDetails.asCanonical() + ) + is WithFeeCredit.V0 -> LiquidityAds.Purchase.WithFeeCredit( + amount = amount, + fees = LiquidityAds.Fees(miningFee = miningFees, serviceFee = serviceFee), + feeCreditUsed = feeCreditUsed, + paymentDetails = paymentDetails.asCanonical() + ) + } + + private fun LiquidityAds.Purchase.asDb(): PurchaseData = when (val value = this) { + is LiquidityAds.Purchase.Standard -> Standard.V0( + amount = value.amount, + miningFees = value.fees.miningFee, + serviceFee = value.fees.serviceFee, + paymentDetails = value.paymentDetails.asDb() + ) + is LiquidityAds.Purchase.WithFeeCredit -> WithFeeCredit.V0( + amount = value.amount, value.fees.miningFee, + serviceFee = value.fees.serviceFee, + paymentDetails = value.paymentDetails.asDb(), + feeCreditUsed = value.feeCreditUsed + ) + } + + /** + * Deserializes a json-encoded blob into a [LiquidityAds.Purchase] object. + * + * @param typeVersion only used for the legacy leased data, where the blob did not contain the type of the object. + */ + @Suppress("DEPRECATION") + fun decodeAsCanonical( + typeVersion: String, + blob: ByteArray, + ): LiquidityAds.Purchase = DbTypesHelper.decodeBlob(blob) { json, format -> + when (typeVersion) { + InboundLiquidityLeaseType.LEASE_V0.name -> format.decodeFromString(json).toLiquidityAdsPurchase() + else -> format.decodeFromString(json).asCanonical() + } + } + + fun LiquidityAds.Purchase.encodeAsDb(): ByteArray = Json.encodeToString(this.asDb()).toByteArray(Charsets.UTF_8) + } +} diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serializers/v1/TxIdSerializer.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serializers/v1/TxIdSerializer.kt new file mode 100644 index 000000000..5924f75a5 --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/serializers/v1/TxIdSerializer.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.db.serializers.v1 + +import fr.acinq.bitcoin.TxId + +object TxIdSerializer : AbstractStringSerializer( + name = "TxId", + toString = TxId::toString, + fromString = ::TxId +) diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/AppConfigurationManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/AppConfigurationManager.kt index 92e189ded..b007f612a 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/AppConfigurationManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/AppConfigurationManager.kt @@ -12,6 +12,7 @@ import fr.acinq.lightning.utils.sat import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.data.* import fr.acinq.phoenix.db.SqliteAppDb +import fr.acinq.phoenix.utils.extensions.phoenixName import fr.acinq.lightning.logging.debug import fr.acinq.lightning.logging.error import io.ktor.client.* @@ -115,7 +116,7 @@ class AppConfigurationManager( }?.let { json -> logger.debug { "fetched wallet-context=$json" } try { - val base = json[chain.name.lowercase()]!! + val base = json[chain.phoenixName]!! val isMempoolFull = base.jsonObject["mempool"]?.jsonObject?.get("v1")?.jsonObject?.get("high_usage")?.jsonPrimitive?.booleanOrNull val androidLatestVersion = base.jsonObject["version"]?.jsonPrimitive?.intOrNull val androidLatestCriticalVersion = base.jsonObject["latest_critical_version"]?.jsonPrimitive?.intOrNull @@ -218,7 +219,8 @@ class AppConfigurationManager( fun randomElectrumServer() = when (chain) { Chain.Mainnet -> mainnetElectrumServers.random() - Chain.Testnet -> testnetElectrumServers.random() + Chain.Testnet3 -> testnetElectrumServers.random() + Chain.Testnet4 -> TODO() Chain.Signet -> TODO() Chain.Regtest -> platformElectrumRegtestConf() } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NodeParamsManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NodeParamsManager.kt index a83405307..90e46464d 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NodeParamsManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NodeParamsManager.kt @@ -88,34 +88,18 @@ class NodeParamsManager( } companion object { - val chain = Chain.Testnet + val chain = Chain.Testnet3 val trampolineNodeId = PublicKey.fromHex("03933884aaf1d6b108397e5efe5c86bcf2d8ca8d2f700eda99db9214fc2712b134") val trampolineNodeUri = NodeUri(id = trampolineNodeId, "13.248.222.197", 9735) const val remoteSwapInXpub = "tpubDAmCFB21J9ExKBRPDcVxSvGs9jtcf8U1wWWbS1xTYmnUsuUHPCoFdCnEGxLE3THSWcQE48GHJnyz8XPbYUivBMbLSMBifFd3G9KmafkM9og" - val defaultLiquidityPolicy = LiquidityPolicy.Auto(maxAbsoluteFee = 5_000.sat, maxRelativeFeeBasisPoints = 50_00 /* 50% */, skipAbsoluteFeeCheck = false) - val payToOpenFeeBase = 100 + val defaultLiquidityPolicy = LiquidityPolicy.Auto( + inboundLiquidityTarget = null, // auto inbound liquidity is disabled (it must be purchased manually) + maxAbsoluteFee = 5_000.sat, + maxRelativeFeeBasisPoints = 50_00 /* 50% */, + skipAbsoluteFeeCheck = false, + maxAllowedFeeCredit = 0.msat, // no fee credit + ) - fun liquidityLeaseRate(amount: Satoshi): LiquidityAds.LeaseRate { - // WARNING : THIS MUST BE KEPT IN SYNC WITH LSP OTHERWISE FUNDING REQUEST WILL BE REJECTED BY PHOENIX - val fundingWeight = if (amount <= 100_000.sat) { - 271 * 2 // 2-inputs (wpkh) / 0-change - } else if (amount <= 250_000.sat) { - 271 * 2 // 2-inputs (wpkh) / 0-change - } else if (amount <= 500_000.sat) { - 271 * 4 // 4-inputs (wpkh) / 0-change - } else if (amount <= 1_000_000.sat) { - 271 * 4 // 4-inputs (wpkh) / 0-change - } else { - 271 * 6 // 6-inputs (wpkh) / 0-change - } - return LiquidityAds.LeaseRate( - leaseDuration = 0, - fundingWeight = fundingWeight, - leaseFeeProportional = 100, // 1% - leaseFeeBase = 0.sat, - maxRelayFeeProportional = 100, - maxRelayFeeBase = 1_000.msat - ) - } + val payToOpenFeeBase = 100 } } \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NotificationsManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NotificationsManager.kt index e13f7b53b..1072adb84 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NotificationsManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/NotificationsManager.kt @@ -99,7 +99,16 @@ class NotificationsManager( amount = event.amount, source = event.source, ) - is LiquidityEvents.Rejected.Reason.ChannelInitializing -> Notification.ChannelsInitializing( + is LiquidityEvents.Rejected.Reason.MissingOffChainAmountTooLow -> Notification.MissingOffChainAmountTooLow( + id = UUID.randomUUID(), + createdAt = currentTimestampMillis(), + readAt = null, + amount = event.amount, + source = event.source, + ) + is LiquidityEvents.Rejected.Reason.ChannelFundingInProgress, + is LiquidityEvents.Rejected.Reason.NoMatchingFundingRate, + is LiquidityEvents.Rejected.Reason.TooManyParts -> Notification.GenericError( id = UUID.randomUUID(), createdAt = currentTimestampMillis(), readAt = null, 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 d5cd14fab..03242393b 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 @@ -153,6 +153,20 @@ class PaymentsManager( return paymentsDb().listPaymentsForTxId(txId) } + /** + * Returns the inbound liquidity purchase relevant to a given transaction id. + * + * It is used to track the fees that may have been incurred by an incoming payment because of low liquidity. To do + * that, we use the transaction id attached to the incoming payment, and find any purchase that matches. + * + * This allows us to display the fees of a liquidity purchase inside an incoming payment details screen. + */ + suspend fun getLiquidityPurchaseForTxId( + txId: TxId + ): InboundLiquidityOutgoingPayment? { + return paymentsDb().getInboundLiquidityPurchase(txId) + } + suspend fun getPayment( id: WalletPaymentId, options: WalletPaymentFetchOptions diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PeerManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PeerManager.kt index 365b02a4a..3fdcda0e4 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PeerManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PeerManager.kt @@ -196,14 +196,12 @@ class PeerManager( client = electrumClient, watcher = electrumWatcher, db = databaseManager.databases.filterNotNull().first(), - trustedSwapInTxs = startupParams.trustedSwapInTxs, socketBuilder = null, scope = MainScope() ) _peer.value = peer launch { monitorNodeEvents(nodeParams) } - launch { updatePeerSwapInFeerate(peer) } // The local channels flow must use `bootFlow` first, as `channelsFlow` is empty when the wallet starts. // `bootFlow` data come from the local database and will be overridden by fresh data once the connection @@ -250,14 +248,6 @@ class PeerManager( getPeer().nodeParams.liquidityPolicy.value = newPolicy } - /** Update the peer's swap-in feerate with values from mempool.space estimator. */ - private suspend fun updatePeerSwapInFeerate(peer: Peer) { - configurationManager.mempoolFeerate.filterNotNull().collect { feerate -> - logger.info { "using mempool.space feerate=$feerate" } - peer.swapInFeeratesFlow.value = FeeratePerKw(feerate.hour) - } - } - private suspend fun monitorNodeEvents(nodeParams: NodeParams) { nodeParams.nodeEvents.collect { event -> logger.debug { "collecting node_event=${event::class.simpleName}" } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/BlockchainExplorer.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/BlockchainExplorer.kt index 4c441e9c8..78dcbf06f 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/BlockchainExplorer.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/BlockchainExplorer.kt @@ -16,7 +16,8 @@ class BlockchainExplorer(private val chain: Chain) { Website.MempoolSpace -> { when (chain) { Chain.Mainnet -> "${website.base}/tx/$txId" - Chain.Testnet -> "${website.base}/testnet/tx/$txId" + Chain.Testnet3 -> "${website.base}/testnet/tx/$txId" + Chain.Testnet4 -> "${website.base}/testnet4/tx/$txId" Chain.Signet -> "${website.base}/signet/tx/$txId" Chain.Regtest -> "${website.base}/_REGTEST_/tx/$txId" } @@ -24,7 +25,8 @@ class BlockchainExplorer(private val chain: Chain) { Website.BlockstreamInfo -> { when (chain) { Chain.Mainnet -> "${website.base}/tx/$txId" - Chain.Testnet -> "${website.base}/testnet/tx/$txId" + Chain.Testnet3 -> "${website.base}/testnet/tx/$txId" + Chain.Testnet4 -> "${website.base}/testnet4/tx/$txId" Chain.Signet -> "${website.base}/signet/tx/$txId" Chain.Regtest -> "${website.base}/_REGTEST_/tx/$txId" } @@ -37,7 +39,8 @@ class BlockchainExplorer(private val chain: Chain) { Website.MempoolSpace -> { when (chain) { Chain.Mainnet -> "${website.base}/address/$addr" - Chain.Testnet -> "${website.base}/testnet/address/$addr" + Chain.Testnet3 -> "${website.base}/testnet/address/$addr" + Chain.Testnet4 -> "${website.base}/testnet4/address/$addr" Chain.Signet -> "${website.base}/signet/address/$addr" Chain.Regtest -> "${website.base}/_REGTEST_/address/$addr" } @@ -45,7 +48,8 @@ class BlockchainExplorer(private val chain: Chain) { Website.BlockstreamInfo -> { when (chain) { Chain.Mainnet -> "${website.base}/address/$addr" - Chain.Testnet -> "${website.base}/testnet/address/$addr" + Chain.Testnet3 -> "${website.base}/testnet/address/$addr" + Chain.Testnet4 -> "${website.base}/testnet4/address/$addr" Chain.Signet -> "${website.base}/signet/address/$addr" Chain.Regtest -> "${website.base}/_REGTEST_/address/$addr" } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/CsvWriter.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/CsvWriter.kt index 0debf1375..31530ad87 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/CsvWriter.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/CsvWriter.kt @@ -1,7 +1,11 @@ package fr.acinq.phoenix.utils import fr.acinq.lightning.db.* +import fr.acinq.lightning.payment.OfferPaymentMetadata +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sum import fr.acinq.phoenix.data.WalletPaymentInfo +import fr.acinq.phoenix.utils.extensions.isManualPurchase import kotlinx.datetime.Instant class CsvWriter { @@ -60,12 +64,28 @@ class CsvWriter { config: Configuration ): String { - val date = info.payment.completedAt ?: info.payment.createdAt + val payment = info.payment + + val date = payment.completedAt ?: info.payment.createdAt val dateStr = Instant.fromEpochMilliseconds(date).toString() // ISO-8601 format var row = processField(dateStr) - val amtMsat = info.payment.amount.msat - val feesMsat = info.payment.fees.msat + val amtMsat = payment.amount.msat + // for the fee, we should ignore the fee returned by lightning parts, because it may contain a funding fee which is already accounted for in the liquidity payments + val feesMsat = when (payment) { + is IncomingPayment -> when (payment.origin) { + is IncomingPayment.Origin.Invoice, is IncomingPayment.Origin.Offer -> { + payment.received?.receivedWith.orEmpty().map { part -> + when (part) { + is IncomingPayment.ReceivedWith.LightningPayment, is IncomingPayment.ReceivedWith.AddedToFeeCredit -> 0.msat + else -> part.fees + } + }.sum().msat + } + else -> payment.fees.msat + } + else -> payment.fees.msat + } val isOutgoing = info.payment is OutgoingPayment val amtMsatStr = if (isOutgoing) "-$amtMsat" else "$amtMsat" @@ -121,22 +141,23 @@ class CsvWriter { is IncomingPayment -> when (val origin = payment.origin) { is IncomingPayment.Origin.Invoice -> "Incoming LN payment" is IncomingPayment.Origin.SwapIn -> "Swap-in to ${origin.address ?: "N/A"}" - is IncomingPayment.Origin.OnChain -> { - "Swap-in with inputs: ${origin.localInputs.map { it.txid.toString() } }" - } - is IncomingPayment.Origin.Offer -> { - "Incoming offer ${origin.metadata.offerId}" - } + is IncomingPayment.Origin.OnChain -> "On-chain deposit" + is IncomingPayment.Origin.Offer -> "Incoming LN payment (offer)" } is LightningOutgoingPayment -> when (val details = payment.details) { is LightningOutgoingPayment.Details.Normal -> "Outgoing LN payment to ${details.paymentRequest.nodeId.toHex()}" - is LightningOutgoingPayment.Details.SwapOut -> "Swap-out to ${details.address}" - is LightningOutgoingPayment.Details.Blinded -> "Offer to ${details.payerKey.publicKey()}" + is LightningOutgoingPayment.Details.SwapOut -> "Outgoing Swap to ${details.address}" + is LightningOutgoingPayment.Details.Blinded -> { + details.paymentRequest.invoiceRequest.offer.contactNodeIds.firstOrNull()?.let { "Outgoing LN payment (offer) to ${it.toHex()}" } + ?: "Outgoing LN payment (offer)" + } } is SpliceOutgoingPayment -> "Outgoing splice to ${payment.address}" is ChannelCloseOutgoingPayment -> "Channel closing to ${payment.address}" is SpliceCpfpOutgoingPayment -> "Accelerate transactions with CPFP" - is InboundLiquidityOutgoingPayment -> "+${payment.lease.amount.sat} sat inbound liquidity" + is InboundLiquidityOutgoingPayment -> + if (payment.isManualPurchase()) "Manual liquidity (+${payment.purchase.amount.sat} sat)" + else "Automated liquidity (+${payment.purchase.amount.sat} sat)" } row += ",${processField(details)}" } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/ChainExtensions.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/ChainExtensions.kt new file mode 100644 index 000000000..1b9313416 --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/ChainExtensions.kt @@ -0,0 +1,13 @@ +package fr.acinq.phoenix.utils.extensions + +import fr.acinq.bitcoin.Chain + +val Chain.phoenixName: String + get() = when (this) { + Chain.Regtest -> "regtest" + Chain.Signet -> "signet" + // Chain.Testnet -> "testnet" + Chain.Testnet3 -> "testnet" + Chain.Testnet4 -> "testnet4" + Chain.Mainnet -> "mainnet" + } \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentExtensions.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentExtensions.kt index 3c9d7eb37..c4a49f7a1 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentExtensions.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentExtensions.kt @@ -17,6 +17,7 @@ package fr.acinq.phoenix.utils.extensions import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.db.LightningOutgoingPayment @@ -25,7 +26,13 @@ import fr.acinq.lightning.db.OutgoingPayment import fr.acinq.lightning.db.WalletPayment import fr.acinq.lightning.payment.Bolt12Invoice import fr.acinq.lightning.payment.OfferPaymentMetadata +import fr.acinq.lightning.utils.getValue +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.utils.sum +import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.lightning.wire.OfferTypes +import fr.acinq.phoenix.data.WalletPaymentId /** Standardized location for extending types from: fr.acinq.lightning. */ enum class WalletPaymentState { SuccessOnChain, SuccessOffChain, PendingOnChain, PendingOffChain, Failure } @@ -65,6 +72,19 @@ fun WalletPayment.state(): WalletPaymentState = when (this) { } } +/** + * Incoming payments may be received (in part or entirely) as a fee credit. This happens when an on-chain operation + * would be necessary to complete the payment, but the amount received is too low to pay for this operation just yet. + * The payment is then accepted, but the amount is accrued to a fee credit. + * + * This fee credit in the wallet is not part of the wallet's balance. It and can only be spent to pay future mining + * or service fees. It serves as a buffer that allows the user to keep accepting incoming payments seamlessly. + * + * Most of the time, this value is null (i.e., the amount received goes to the balance). + */ +val IncomingPayment.amountFeeCredit : MilliSatoshi? + get() = this.received?.receivedWith?.filterIsInstance()?.map { it.amountReceived }?.sum() + fun WalletPayment.paymentHashString(): String = when (this) { is OnChainOutgoingPayment -> throw NotImplementedError("no payment hash for on-chain outgoing") is LightningOutgoingPayment -> paymentHash.toString() @@ -82,3 +102,27 @@ fun WalletPayment.errorMessage(): String? = when (this) { fun WalletPayment.incomingOfferMetadata(): OfferPaymentMetadata.V1? = ((this as? IncomingPayment)?.origin as? IncomingPayment.Origin.Offer)?.metadata as? OfferPaymentMetadata.V1 fun WalletPayment.outgoingInvoiceRequest(): OfferTypes.InvoiceRequest? = ((this as? LightningOutgoingPayment)?.details as? LightningOutgoingPayment.Details.Blinded)?.paymentRequest?.invoiceRequest + +/** Returns a list of the ids of the payments that triggered this liquidity purchase. May be empty, for example if this is a manual purchase. */ +fun InboundLiquidityOutgoingPayment.relatedPaymentIds() : List = when (val details = purchase.paymentDetails) { + is LiquidityAds.PaymentDetails.FromFutureHtlc -> details.paymentHashes + is LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc -> details.paymentHashes + else -> emptyList() +}.map { WalletPaymentId.IncomingPaymentId(it) } + +/** + * Returns true if this liquidity was initiated manually by the user, false otherwise. + * + * FIXME: Dangerous!! + * In general, FromChannelBalance only happens for manual purchases OR automated swap-ins with additional liquidity. + * However, swap-ins do not **yet** request additional liquidity, so **for now** we can make a safe approximation. + * Eventually, once swap-ins are upgraded to request liquidity, this will have to be fixed. + */ +fun InboundLiquidityOutgoingPayment.isManualPurchase(): Boolean = purchase.paymentDetails is LiquidityAds.PaymentDetails.FromChannelBalance + +/** + * Returns true if the liquidity fee was paid by an htlc in a future incoming payment. When that's the case, we should + * not display the fees in the liquidity details screen to avoid confusion. Instead we should link to the payment whose + * HTLCs paid the fees. + */ +fun InboundLiquidityOutgoingPayment.isPaidInTheFuture(): Boolean = feePaidFromChannelBalance.total == 0.sat \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentRequestExtensions.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentRequestExtensions.kt index d191caa0e..e29b00379 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentRequestExtensions.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/extensions/PaymentRequestExtensions.kt @@ -36,7 +36,7 @@ val PaymentRequest.chain: Chain is Bolt11Invoice -> { when (prefix) { "lnbc" -> Chain.Mainnet - "lntb" -> Chain.Testnet + "lntb" -> Chain.Testnet3 "lnbcrt" -> Chain.Regtest else -> throw IllegalArgumentException("unhandled invoice prefix=$prefix") } 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 6f8ba0b1a..fd680c4e9 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 @@ -14,42 +14,48 @@ FROM ( 2 AS type, id AS id, created_at AS created_at, - completed_at AS completed_at + completed_at AS completed_at, + created_at AS order_ts FROM outgoing_payments UNION ALL SELECT 3 AS type, id AS id, created_at AS created_at, - confirmed_at AS completed_at + confirmed_at AS completed_at, + created_at AS order_ts FROM splice_outgoing_payments UNION ALL SELECT 4 AS type, id AS id, created_at AS created_at, - confirmed_at AS completed_at + confirmed_at AS completed_at, + created_at AS order_ts FROM channel_close_outgoing_payments UNION ALL SELECT 5 AS type, id AS id, created_at AS created_at, - confirmed_at AS completed_at + confirmed_at AS completed_at, + created_at AS order_ts FROM splice_cpfp_outgoing_payments UNION ALL SELECT 6 AS type, id AS id, created_at AS created_at, - locked_at AS completed_at + locked_at AS completed_at, + created_at AS order_ts FROM inbound_liquidity_outgoing_payments UNION ALL SELECT 1 AS type, lower(hex(payment_hash)) AS id, created_at AS created_at, - received_at AS completed_at + received_at AS completed_at, + received_at AS order_ts FROM incoming_payments WHERE incoming_payments.received_at IS NOT NULL AND incoming_payments.received_with_blob IS NOT NULL @@ -57,7 +63,7 @@ UNION ALL LEFT OUTER JOIN payments_metadata ON payments_metadata.type = combined_payments.type AND payments_metadata.id = combined_payments.id -ORDER BY combined_payments.created_at DESC +ORDER BY COALESCE(combined_payments.order_ts, combined_payments.created_at) DESC LIMIT :limit OFFSET :offset; listAllPaymentsCount: diff --git a/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/InboundLiquidityOutgoing.sq b/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/InboundLiquidityOutgoing.sq index aa84ff6a5..565b84644 100644 --- a/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/InboundLiquidityOutgoing.sq +++ b/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/InboundLiquidityOutgoing.sq @@ -1,5 +1,3 @@ -import fr.acinq.phoenix.db.payments.InboundLiquidityLeaseTypeVersion; - -- Stores in a flat row payments standing for an inbound liquidity request (which are done through a splice). -- The lease data are stored in a complex column, as a json-encoded blob. See InboundLiquidityLeaseType file. CREATE TABLE IF NOT EXISTS inbound_liquidity_outgoing_payments ( @@ -7,8 +5,9 @@ CREATE TABLE IF NOT EXISTS inbound_liquidity_outgoing_payments ( mining_fees_sat INTEGER NOT NULL, channel_id BLOB NOT NULL, tx_id BLOB NOT NULL, - lease_type TEXT AS InboundLiquidityLeaseTypeVersion NOT NULL, + lease_type TEXT NOT NULL, lease_blob BLOB NOT NULL, + payment_details_type TEXT DEFAULT NULL, created_at INTEGER NOT NULL, confirmed_at INTEGER DEFAULT NULL, locked_at INTEGER DEFAULT NULL @@ -16,8 +15,8 @@ CREATE TABLE IF NOT EXISTS inbound_liquidity_outgoing_payments ( insert: INSERT INTO inbound_liquidity_outgoing_payments ( - id, mining_fees_sat, channel_id, tx_id, lease_type, lease_blob, created_at, confirmed_at, locked_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); + id, mining_fees_sat, channel_id, tx_id, lease_type, lease_blob, payment_details_type, created_at, confirmed_at, locked_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); setConfirmed: UPDATE inbound_liquidity_outgoing_payments SET confirmed_at=? WHERE id=?; @@ -30,5 +29,10 @@ SELECT id, mining_fees_sat, channel_id, tx_id, lease_type, lease_blob, created_a FROM inbound_liquidity_outgoing_payments WHERE id=?; +getByTxId: +SELECT id, mining_fees_sat, channel_id, tx_id, lease_type, lease_blob, created_at, confirmed_at, locked_at +FROM inbound_liquidity_outgoing_payments +WHERE tx_id=?; + delete: DELETE FROM inbound_liquidity_outgoing_payments WHERE id=?; diff --git a/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/migrations/9.sqm b/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/migrations/9.sqm new file mode 100644 index 000000000..0b8973f65 --- /dev/null +++ b/phoenix-shared/src/commonMain/paymentsdb/fr.acinq.phoenix.db/migrations/9.sqm @@ -0,0 +1,8 @@ +import fr.acinq.phoenix.db.payments.InboundLiquidityLeaseTypeVersion; + +-- Migration: v9 -> v10 +-- +-- Changes: +-- * Added a new column [payment_details_type] in table [inbound_liquidity_outgoing_payments] + +ALTER TABLE inbound_liquidity_outgoing_payments ADD COLUMN payment_details_type TEXT DEFAULT NULL; diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/data/lnurl/LnurlAuthTest.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/data/lnurl/LnurlAuthTest.kt index d29c6864c..8c621305a 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/data/lnurl/LnurlAuthTest.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/data/lnurl/LnurlAuthTest.kt @@ -27,7 +27,7 @@ import kotlin.test.assertEquals class LnurlAuthTest { private val mnemonics = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" private val seed = MnemonicCode.toSeed(mnemonics, passphrase = "").toByteVector() - private val keyManager = LocalKeyManager(seed, Chain.Testnet, remoteSwapInExtendedPublicKey = "tpubDDt5vQap1awkyDXx1z1cP7QFKSZHDCCpbU8nSq9jy7X2grTjUVZDePexf6gc6AHtRRzkgfPW87K6EKUVV6t3Hu2hg7YkHkmMeLSfrP85x41") + private val keyManager = LocalKeyManager(seed, Chain.Testnet3, remoteSwapInExtendedPublicKey = "tpubDDt5vQap1awkyDXx1z1cP7QFKSZHDCCpbU8nSq9jy7X2grTjUVZDePexf6gc6AHtRRzkgfPW87K6EKUVV6t3Hu2hg7YkHkmMeLSfrP85x41") @Test fun specs_test_vectors() { diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/IncomingPaymentDbTypeVersionTest.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/IncomingPaymentDbTypeVersionTest.kt index e20110e97..6fa68f9f0 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/IncomingPaymentDbTypeVersionTest.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/IncomingPaymentDbTypeVersionTest.kt @@ -62,7 +62,7 @@ class IncomingPaymentDbTypeVersionTest { @Test @Suppress("DEPRECATION") fun incoming_receivedwith_multipart_v0_lightning() { - val receivedWith = listOf(IncomingPayment.ReceivedWith.LightningPayment(100_000.msat, ByteVector32.One, 2L)) + val receivedWith = listOf(IncomingPayment.ReceivedWith.LightningPayment(100_000.msat, ByteVector32.One, 2L, null)) val deserialized = IncomingReceivedWithData.deserialize( IncomingReceivedWithTypeVersion.MULTIPARTS_V0, receivedWith.mapToDb()!!.second, @@ -74,7 +74,7 @@ class IncomingPaymentDbTypeVersionTest { @Test fun incoming_receivedwith_multipart_v1_lightning() { - val receivedWith = listOf(IncomingPayment.ReceivedWith.LightningPayment(100_000.msat, ByteVector32.One, 2L)) + val receivedWith = listOf(IncomingPayment.ReceivedWith.LightningPayment(100_000.msat, ByteVector32.One, 2L, null)) val deserialized = IncomingReceivedWithData.deserialize( IncomingReceivedWithTypeVersion.MULTIPARTS_V1, receivedWith.mapToDb()!!.second, @@ -146,7 +146,7 @@ class IncomingPaymentDbTypeVersionTest { IncomingOriginTypeVersion.INVOICE_V0 ).first() as IncomingPayment.ReceivedWith.LightningPayment - assertEquals(999_999.msat, deserialized.amount) + assertEquals(999_999.msat, deserialized.amountReceived) assertEquals(0.msat, deserialized.fees) assertEquals(ByteVector32.Zeroes, deserialized.channelId) assertEquals(0L, deserialized.htlcId) @@ -162,7 +162,7 @@ class IncomingPaymentDbTypeVersionTest { IncomingOriginTypeVersion.SWAPIN_V0 ) .first() as IncomingPayment.ReceivedWith.NewChannel - assertEquals(123_456.msat, deserialized.amount) + assertEquals(123_456.msat, deserialized.amountReceived) assertEquals(15_000.msat, deserialized.fees) assertEquals(channelId1, deserialized.channelId) } diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/SqlitePaymentsDatabaseTest.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/SqlitePaymentsDatabaseTest.kt index aef6f06b9..6e468fcc7 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/SqlitePaymentsDatabaseTest.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/SqlitePaymentsDatabaseTest.kt @@ -21,14 +21,11 @@ import fr.acinq.bitcoin.* import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* import fr.acinq.lightning.Lightning.randomBytes32 -import fr.acinq.lightning.channel.TooManyAcceptedHtlcs import fr.acinq.lightning.db.* import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.payment.FinalFailure -import fr.acinq.lightning.payment.OutgoingPaymentFailure -import fr.acinq.lightning.payment.PaymentRequest import fr.acinq.lightning.utils.* -import fr.acinq.lightning.wire.TemporaryNodeFailure +import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.phoenix.data.WalletPaymentFetchOptions import fr.acinq.phoenix.runTest import fr.acinq.phoenix.utils.migrations.LegacyChannelCloseHelper @@ -43,12 +40,12 @@ class SqlitePaymentsDatabaseTest { private val paymentHash1 = Crypto.sha256(preimage1).toByteVector32() private val origin1 = IncomingPayment.Origin.Invoice(createInvoice(preimage1)) private val channelId1 = randomBytes32() - private val receivedWith1 = listOf(IncomingPayment.ReceivedWith.LightningPayment(100_000.msat, channelId1, 1L)) - private val receivedWith3 = listOf(IncomingPayment.ReceivedWith.LightningPayment(150_000.msat, channelId1, 1L)) + private val receivedWith1 = listOf(IncomingPayment.ReceivedWith.LightningPayment(100_000.msat, channelId1, 1L, null)) + private val receivedWith3 = listOf(IncomingPayment.ReceivedWith.LightningPayment(150_000.msat, channelId1, 1L, fundingFee = LiquidityAds.FundingFee(2_000.msat, TxId(randomBytes32())))) private val preimage2 = randomBytes32() private val receivedWith2 = listOf( - IncomingPayment.ReceivedWith.NewChannel(amount = 1_995_000.msat, serviceFee = 5_000.msat, channelId = randomBytes32(), txId = TxId(randomBytes32()), miningFee = 100.sat, confirmedAt = 100, lockedAt = 200) + IncomingPayment.ReceivedWith.NewChannel(amountReceived = 1_995_000.msat, serviceFee = 5_000.msat, channelId = randomBytes32(), txId = TxId(randomBytes32()), miningFee = 100.sat, confirmedAt = 100, lockedAt = 200) ) val origin3 = IncomingPayment.Origin.SwapIn(address = "1PwLgmRdDjy5GAKWyp8eyAC4SFzWuboLLb") @@ -90,8 +87,8 @@ class SqlitePaymentsDatabaseTest { val origin = IncomingPayment.Origin.Invoice(createInvoice(preimage, 1_000_000_000.msat)) val channelId = randomBytes32() val txId = TxId(randomBytes32()) - val mppPart1 = IncomingPayment.ReceivedWith.NewChannel(amount = 600_000_000.msat, serviceFee = 5_000.msat, miningFee = 100.sat, channelId = channelId, txId = txId, confirmedAt = 100, lockedAt = 50) - val mppPart2 = IncomingPayment.ReceivedWith.NewChannel(amount = 400_000_000.msat, serviceFee = 5_000.msat, miningFee = 200.sat, channelId = channelId, txId = txId, confirmedAt = 115, lockedAt = 75) + val mppPart1 = IncomingPayment.ReceivedWith.NewChannel(amountReceived = 600_000_000.msat, serviceFee = 5_000.msat, miningFee = 100.sat, channelId = channelId, txId = txId, confirmedAt = 100, lockedAt = 50) + val mppPart2 = IncomingPayment.ReceivedWith.NewChannel(amountReceived = 400_000_000.msat, serviceFee = 5_000.msat, miningFee = 200.sat, channelId = channelId, txId = txId, confirmedAt = 115, lockedAt = 75) val receivedWith = listOf(mppPart1, mppPart2) db.addIncomingPayment(preimage, origin, 0) @@ -106,8 +103,8 @@ class SqlitePaymentsDatabaseTest { val origin = IncomingPayment.Origin.Invoice(createInvoice(preimage, 1_000_000_000.msat)) val channelId = randomBytes32() val txId = TxId(randomBytes32()) - val mppPart1 = IncomingPayment.ReceivedWith.NewChannel(amount = 500_000_000.msat, serviceFee = 5_000.msat, miningFee = 200.sat, channelId = channelId, txId = txId, confirmedAt = 100, lockedAt = 50) - val mppPart2 = IncomingPayment.ReceivedWith.NewChannel(amount = 500_000_000.msat, serviceFee = 5_000.msat, miningFee = 150.sat, channelId = channelId, txId = txId, confirmedAt = 115, lockedAt = 75) + val mppPart1 = IncomingPayment.ReceivedWith.NewChannel(amountReceived = 500_000_000.msat, serviceFee = 5_000.msat, miningFee = 200.sat, channelId = channelId, txId = txId, confirmedAt = 100, lockedAt = 50) + val mppPart2 = IncomingPayment.ReceivedWith.NewChannel(amountReceived = 500_000_000.msat, serviceFee = 5_000.msat, miningFee = 150.sat, channelId = channelId, txId = txId, confirmedAt = 115, lockedAt = 75) val receivedWith = listOf(mppPart1, mppPart2) db.addIncomingPayment(preimage, origin, 0) @@ -154,7 +151,7 @@ class SqlitePaymentsDatabaseTest { fun incoming__purge_expired() = runTest { val expiredPreimage = randomBytes32() val expiredInvoice = Bolt11Invoice.create( - chain = Chain.Testnet, + chain = Chain.Testnet3, amount = 150_000.msat, paymentHash = Crypto.sha256(expiredPreimage).toByteVector32(), privateKey = Lightning.randomKey(), diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/cloud/CloudDataTest.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/cloud/CloudDataTest.kt index adcf64408..5ff812723 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/cloud/CloudDataTest.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/cloud/CloudDataTest.kt @@ -9,6 +9,7 @@ import fr.acinq.lightning.db.* import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.payment.FinalFailure import fr.acinq.lightning.utils.* +import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.phoenix.runTest import fr.acinq.secp256k1.Hex import kotlin.test.* @@ -86,10 +87,10 @@ class CloudDataTest { fun incoming__receivedWith_lightning() = runTest { val invoice = createBolt11Invoice(preimage, 250_000.msat) val receivedWith1 = IncomingPayment.ReceivedWith.LightningPayment( - amount = 100_000.msat, channelId = channelId, htlcId = 1L + amountReceived = 100_000.msat, channelId = channelId, htlcId = 1L, fundingFee = null ) val receivedWith2 = IncomingPayment.ReceivedWith.LightningPayment( - amount = 150_000.msat, channelId = channelId, htlcId = 1L + amountReceived = 150_000.msat, channelId = channelId, htlcId = 1L, fundingFee = LiquidityAds.FundingFee(amount = 1_000.msat, TxId(ByteVector32.Zeroes)) ) testRoundtrip( IncomingPayment( @@ -104,7 +105,7 @@ class CloudDataTest { fun incoming__receivedWith_newChannel() = runTest { val invoice = createBolt11Invoice(preimage, 10_000_000.msat) val receivedWith = IncomingPayment.ReceivedWith.NewChannel( - amount = 7_000_000.msat, miningFee = 2_000.sat, serviceFee = 1_000_000.msat, channelId = channelId, txId = TxId(randomBytes32()), confirmedAt = 500, lockedAt = 800 + amountReceived = 7_000_000.msat, miningFee = 2_000.sat, serviceFee = 1_000_000.msat, channelId = channelId, txId = TxId(randomBytes32()), confirmedAt = 500, lockedAt = 800 ) testRoundtrip( IncomingPayment( @@ -127,8 +128,8 @@ class CloudDataTest { val expectedChannelId = Hex.decode("e8a0e7ba91a485ed6857415cc0c60f77eda6cb1ebe1da841d42d7b4388cc2bcc").byteVector32() val expectedReceived = IncomingPayment.Received( receivedWith = listOf( - IncomingPayment.ReceivedWith.NewChannel(amount = 7_000_000.msat, miningFee = 0.sat, serviceFee = 3_000_000.msat, channelId = expectedChannelId, txId = TxId(ByteVector32.Zeroes), confirmedAt = 0, lockedAt = 0), - IncomingPayment.ReceivedWith.NewChannel(amount = 9_000_000.msat, miningFee = 0.sat, serviceFee = 6_000_000.msat, channelId = expectedChannelId, txId = TxId(ByteVector32.Zeroes), confirmedAt = 0, lockedAt = 0) + IncomingPayment.ReceivedWith.NewChannel(amountReceived = 7_000_000.msat, miningFee = 0.sat, serviceFee = 3_000_000.msat, channelId = expectedChannelId, txId = TxId(ByteVector32.Zeroes), confirmedAt = 0, lockedAt = 0), + IncomingPayment.ReceivedWith.NewChannel(amountReceived = 9_000_000.msat, miningFee = 0.sat, serviceFee = 6_000_000.msat, channelId = expectedChannelId, txId = TxId(ByteVector32.Zeroes), confirmedAt = 0, lockedAt = 0) ), receivedAt = 1658246347319 ) diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/CsvWriterTests.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/CsvWriterTests.kt index 3fabedb5c..46d3491a1 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/CsvWriterTests.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/CsvWriterTests.kt @@ -20,6 +20,7 @@ import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.currentTimestampSeconds import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.phoenix.TestConstants import fr.acinq.phoenix.data.ExchangeRate import fr.acinq.phoenix.data.FiatCurrency @@ -42,7 +43,7 @@ class CsvWriterTests { received = IncomingPayment.Received( receivedWith = listOf( IncomingPayment.ReceivedWith.NewChannel( - amount = 12_000_000.msat, + amountReceived = 12_000_000.msat, serviceFee = 3_000_000.msat, miningFee = 0.sat, channelId = randomBytes32(), @@ -78,9 +79,10 @@ class CsvWriterTests { received = IncomingPayment.Received( receivedWith = listOf( IncomingPayment.ReceivedWith.LightningPayment( - amount = 2_173_929.msat, + amountReceived = 2_173_929.msat, channelId = randomBytes32(), - htlcId = 0 + htlcId = 0, + fundingFee = LiquidityAds.FundingFee(2_000.msat, TxId(randomBytes32())) ) ), receivedAt = 1675270484965 @@ -238,7 +240,7 @@ class CsvWriterTests { received = IncomingPayment.Received( receivedWith = listOf( IncomingPayment.ReceivedWith.NewChannel( - amount = 12_000_000.msat, + amountReceived = 12_000_000.msat, serviceFee = 2_931_000.msat, miningFee = 69.sat, channelId = randomBytes32(), @@ -256,7 +258,7 @@ class CsvWriterTests { userNotes = "Via dual-funding flow" ) - val expected = "2023-02-01T17:14:43.668Z,12000000,-3000000,2.7599 USD,-0.6899 USD,Swap-in with inputs: [${input.txid}],L1 Top-up,Via dual-funding flow\r\n" + val expected = "2023-02-01T17:14:43.668Z,12000000,-3000000,2.7599 USD,-0.6899 USD,On-chain deposit,L1 Top-up,Via dual-funding flow\r\n" val actual = CsvWriter.makeRow( info = WalletPaymentInfo(payment, metadata, null, WalletPaymentFetchOptions.All), localizedDescription = "L1 Top-up", @@ -291,7 +293,7 @@ class CsvWriterTests { userNotes = null ) - val expected = "2023-02-01T22:16:54.498Z,-12820000,-2820000,-3.0366 USD,-0.6679 USD,Swap-out to tb1qlywh0dk40k87gqphpfs8kghd96hmnvus7r8hhf,Swap for cash,\r\n" + val expected = "2023-02-01T22:16:54.498Z,-12820000,-2820000,-3.0366 USD,-0.6679 USD,Outgoing Swap to tb1qlywh0dk40k87gqphpfs8kghd96hmnvus7r8hhf,Swap for cash,\r\n" val actual = CsvWriter.makeRow( info = WalletPaymentInfo(payment, metadata, null, WalletPaymentFetchOptions.All), localizedDescription = "Swap for cash", @@ -337,7 +339,7 @@ class CsvWriterTests { */ private fun makePaymentRequest() = Bolt11Invoice.create( - chain = Chain.Testnet, + chain = Chain.Testnet3, amount = 10_000.msat, paymentHash = randomBytes32(), privateKey = PrivateKey(value = randomBytes32()), diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/ParserTest.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/ParserTest.kt index df2e1a878..7cd194de2 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/ParserTest.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/ParserTest.kt @@ -41,18 +41,18 @@ class ParserTest { assertIs>(Parser.parseBip21Uri(Chain.Mainnet, "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")) assertIs>(Parser.parseBip21Uri(Chain.Mainnet, "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3")) - assertIs>(Parser.parseBip21Uri(Chain.Testnet, "mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn")) - assertIs>(Parser.parseBip21Uri(Chain.Testnet, "2MzQwSSnBHWHqSAqtTVQ6v47XtaisrJa1Vc")) - assertIs>(Parser.parseBip21Uri(Chain.Testnet, "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx")) - assertIs>(Parser.parseBip21Uri(Chain.Testnet, "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7")) - assertIs>(Parser.parseBip21Uri(Chain.Testnet, "tb1p607g5ea77m370pey3y5rg58fz7542hnpg40rs2cqw6w69yt5lf2qlktj2a")) + assertIs>(Parser.parseBip21Uri(Chain.Testnet3, "mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn")) + assertIs>(Parser.parseBip21Uri(Chain.Testnet3, "2MzQwSSnBHWHqSAqtTVQ6v47XtaisrJa1Vc")) + assertIs>(Parser.parseBip21Uri(Chain.Testnet3, "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx")) + assertIs>(Parser.parseBip21Uri(Chain.Testnet3, "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7")) + assertIs>(Parser.parseBip21Uri(Chain.Testnet3, "tb1p607g5ea77m370pey3y5rg58fz7542hnpg40rs2cqw6w69yt5lf2qlktj2a")) } @Test fun parse_bitcoin_uri_chain_mismatch() { assertEquals( expected = Either.Left(BitcoinUriError.InvalidScript(error = BitcoinError.ChainHashMismatch)), - actual = Parser.parseBip21Uri(Chain.Testnet, "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3") + actual = Parser.parseBip21Uri(Chain.Testnet3, "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3") ) assertEquals( expected = Either.Left(BitcoinUriError.InvalidScript(error = BitcoinError.ChainHashMismatch)), @@ -77,7 +77,7 @@ class ParserTest { ) assertIs>( Parser.parseBip21Uri( - Chain.Testnet, + Chain.Testnet3, "bitcoin:?lno=lno1qgsyxjtl6luzd9t3pr62xr7eemp6awnejusgf6gw45q75vcfqqqqqqqsespexwyy4tcadvgg89l9aljus6709kx235hhqrk6n8dey98uyuftzdqrt2gkjvf2rj2vnt7m7chnmazen8wpur2h65ttgftkqaugy6ql9dcsyq39xc2g084xfn0s50zlh2ex22vvaqxqz3vmudklz453nns4d0624sqr8ux4p5usm22qevld4ydfck7hwgcg9wc3f78y7jqhc6hwdq7e9dwkhty3svq5ju4dptxtldjumlxh5lw48jsz6pnagtwrmeus7uq9rc5g6uddwcwldpklxexvlezld8egntua4gsqqy8auz966nksacdac8yv3maq6elp" ) ) @@ -129,30 +129,33 @@ class ParserTest { @Test fun parse_bitcoin_uri_with_lightning_invoice() { - listOf>>( - // valid lightning invoice - "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?foo=bar&lightning=lntb15u1p05vazrpp5apz75ghtq3ynmc5qm98tsgucmsav44fyffpguhzdep2kcgkfme4sdq4xysyymr0vd4kzcmrd9hx7cqp2xqrrss9qy9qsqsp5v4hqr48qe0u7al6lxwdpmp3w6k7evjdavm0lh7arpv3qaf038s5st2d8k8vvmxyav2wkfym9jp4mk64srmswgh7l6sqtq7l4xl3nknf8snltamvpw5p3yl9nxg0ax9k0698rr94qx6unrv8yhccmh4z9ghcq77hxps" to Either.Right( - BitcoinUri( - chain = Chain.Mainnet, - address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", - script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6"), - paymentRequest = Bolt11Invoice.read("lntb15u1p05vazrpp5apz75ghtq3ynmc5qm98tsgucmsav44fyffpguhzdep2kcgkfme4sdq4xysyymr0vd4kzcmrd9hx7cqp2xqrrss9qy9qsqsp5v4hqr48qe0u7al6lxwdpmp3w6k7evjdavm0lh7arpv3qaf038s5st2d8k8vvmxyav2wkfym9jp4mk64srmswgh7l6sqtq7l4xl3nknf8snltamvpw5p3yl9nxg0ax9k0698rr94qx6unrv8yhccmh4z9ghcq77hxps") - .get(), - ignoredParams = ParametersBuilder().apply { set("foo", "bar") }.build() - ) - ), - // invalid lightning invoice - "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?lightning=lntb15u1p05vazrpp" to Either.Right( - BitcoinUri(chain = Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6")) - ), - // empty lightning invoice - "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?lightning=" to Either.Right( - BitcoinUri(chain = Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6")) + assertEquals( + expected = BitcoinUri( + chain = Chain.Mainnet, + address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6"), + paymentRequest = Bolt11Invoice.read("lnbc10u1pn08ld4pp5x7vvjs56tsj65c5xxe43xtxc22n6umuc89hwjndkwkazduzqhsesdpcge6kuerfdenjqspsxvcxxd33vseryefqdahzqum5v93kketj9ehx2amncqzzsxqrrs0sp5g2gjnwnsmy7xfprvjsuppymeqvr0zm3tksmqtg2sqdyqmxaxasxq9qxpqysgq3rj5d9vx7vsmfe8pxzqx7jzes77sta32yp9rqx78dkh4fn8lg8mk9kzh29255qgamcdddf30pp6hptk0u432sg39h3rjxru0ec5edycpxpqmg3").get(), + ignoredParams = ParametersBuilder().apply { set("foo", "bar") }.build() ), - ).forEach { (address, expected) -> - val uri = Parser.parseBip21Uri(Chain.Mainnet, address) - assertEquals(expected, uri) - } + actual = Parser.parseBip21Uri( + chain = Chain.Mainnet, + input = "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?foo=bar&lightning=lnbc10u1pn08ld4pp5x7vvjs56tsj65c5xxe43xtxc22n6umuc89hwjndkwkazduzqhsesdpcge6kuerfdenjqspsxvcxxd33vseryefqdahzqum5v93kketj9ehx2amncqzzsxqrrs0sp5g2gjnwnsmy7xfprvjsuppymeqvr0zm3tksmqtg2sqdyqmxaxasxq9qxpqysgq3rj5d9vx7vsmfe8pxzqx7jzes77sta32yp9rqx78dkh4fn8lg8mk9kzh29255qgamcdddf30pp6hptk0u432sg39h3rjxru0ec5edycpxpqmg3" + ).right + ) + assertEquals( + expected = BitcoinUri(chain = Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6"), paymentRequest = null), + actual = Parser.parseBip21Uri( + chain = Chain.Mainnet, + input = "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?lightning=lntb15u1p05vazrpp" + ).right + ) + assertEquals( + expected = BitcoinUri(chain = Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6"), paymentRequest = null), + actual = Parser.parseBip21Uri( + chain = Chain.Mainnet, + input = "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?lightning=" + ).right + ) } @Test diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/iosDbFactory.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/iosDbFactory.kt index 12ec20df2..c7dc1689c 100644 --- a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/iosDbFactory.kt +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/iosDbFactory.kt @@ -23,6 +23,7 @@ import app.cash.sqldelight.driver.native.wrapConnection import fr.acinq.bitcoin.Chain import fr.acinq.phoenix.utils.PlatformContext import fr.acinq.phoenix.utils.getDatabaseFilesDirectoryPath +import fr.acinq.phoenix.utils.extensions.phoenixName actual fun createChannelsDbDriver( ctx: PlatformContext, @@ -30,7 +31,7 @@ actual fun createChannelsDbDriver( nodeIdHash: String ): SqlDriver { val schema = ChannelsDatabase.Schema - val name = "channels-${chain.name.lowercase()}-$nodeIdHash.sqlite" + val name = "channels-${chain.phoenixName}-$nodeIdHash.sqlite" // The foreign_keys constraint needs to be set via the DatabaseConfiguration: // https://github.com/cashapp/sqldelight/issues/1356 @@ -59,7 +60,7 @@ actual fun createPaymentsDbDriver( nodeIdHash: String ): SqlDriver { val schema = PaymentsDatabase.Schema - val name = "payments-${chain.name.lowercase()}-$nodeIdHash.sqlite" + val name = "payments-${chain.phoenixName}-$nodeIdHash.sqlite" val dbDir = getDatabaseFilesDirectoryPath(ctx) val configuration = DatabaseConfiguration( diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/LightningExposure.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/LightningExposure.kt index eb8553520..d7cc22cca 100644 --- a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/LightningExposure.kt +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/LightningExposure.kt @@ -3,13 +3,10 @@ package fr.acinq.phoenix.utils import fr.acinq.bitcoin.ByteVector import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.ByteVector64 -import fr.acinq.bitcoin.Crypto import fr.acinq.bitcoin.PrivateKey -import fr.acinq.bitcoin.PublicKey import fr.acinq.bitcoin.Satoshi import fr.acinq.bitcoin.Transaction import fr.acinq.bitcoin.TxId -import fr.acinq.bitcoin.byteVector import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.ChannelEvents import fr.acinq.lightning.DefaultSwapInParams @@ -23,16 +20,13 @@ import fr.acinq.lightning.blockchain.electrum.ElectrumClient import fr.acinq.lightning.blockchain.electrum.ElectrumMiniWallet import fr.acinq.lightning.blockchain.electrum.WalletState import fr.acinq.lightning.blockchain.fee.FeeratePerKw -import fr.acinq.lightning.channel.ChannelCommand -import fr.acinq.lightning.channel.ChannelManagementFees +import fr.acinq.lightning.channel.ChannelFundingResponse import fr.acinq.lightning.channel.states.Aborted import fr.acinq.lightning.channel.states.ChannelState import fr.acinq.lightning.channel.states.Closed import fr.acinq.lightning.channel.states.Closing import fr.acinq.lightning.channel.states.Offline import fr.acinq.lightning.crypto.KeyManager -import fr.acinq.lightning.crypto.LocalKeyManager -import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.db.LightningOutgoingPayment import fr.acinq.lightning.io.NativeSocketException @@ -51,22 +45,19 @@ import fr.acinq.lightning.payment.LiquidityPolicy import fr.acinq.lightning.payment.OutgoingPaymentFailure import fr.acinq.lightning.utils.Connection import fr.acinq.lightning.utils.UUID -import fr.acinq.lightning.utils.concat import fr.acinq.lightning.utils.copyTo import fr.acinq.lightning.utils.toByteArray import fr.acinq.lightning.utils.toNSData import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.lightning.wire.OfferTypes -import fr.acinq.phoenix.managers.cloudKey -import io.ktor.utils.io.core.toByteArray import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import platform.Foundation.NSData -import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds /** @@ -76,35 +67,24 @@ import kotlin.time.Duration.Companion.seconds * This problem is restricted to iOS, and does not affect Android. */ -fun IncomingPayment.Origin.asInvoice(): IncomingPayment.Origin.Invoice? = when (this) { - is IncomingPayment.Origin.Invoice -> this - else -> null -} +fun IncomingPayment.Origin.asInvoice(): IncomingPayment.Origin.Invoice? = + (this as? IncomingPayment.Origin.Invoice) -fun IncomingPayment.Origin.asSwapIn(): IncomingPayment.Origin.SwapIn? = when (this) { - is IncomingPayment.Origin.SwapIn -> this - else -> null -} +fun IncomingPayment.Origin.asSwapIn(): IncomingPayment.Origin.SwapIn? = + (this as? IncomingPayment.Origin.SwapIn) -fun IncomingPayment.Origin.asOnChain(): IncomingPayment.Origin.OnChain? = when (this) { - is IncomingPayment.Origin.OnChain -> this - else -> null -} +fun IncomingPayment.Origin.asOnChain(): IncomingPayment.Origin.OnChain? = + (this as? IncomingPayment.Origin.OnChain) -fun IncomingPayment.ReceivedWith.asLightningPayment(): IncomingPayment.ReceivedWith.LightningPayment? = when (this) { - is IncomingPayment.ReceivedWith.LightningPayment -> this - else -> null -} +fun IncomingPayment.ReceivedWith.asLightningPayment(): + IncomingPayment.ReceivedWith.LightningPayment? = + (this as? IncomingPayment.ReceivedWith.LightningPayment) -fun IncomingPayment.ReceivedWith.asNewChannel(): IncomingPayment.ReceivedWith.NewChannel? = when (this) { - is IncomingPayment.ReceivedWith.NewChannel -> this - else -> null -} +fun IncomingPayment.ReceivedWith.asNewChannel(): IncomingPayment.ReceivedWith.NewChannel? = + (this as? IncomingPayment.ReceivedWith.NewChannel) -fun IncomingPayment.ReceivedWith.asSpliceIn(): IncomingPayment.ReceivedWith.SpliceIn? = when (this) { - is IncomingPayment.ReceivedWith.SpliceIn -> this - else -> null -} +fun IncomingPayment.ReceivedWith.asSpliceIn(): IncomingPayment.ReceivedWith.SpliceIn? = + (this as? IncomingPayment.ReceivedWith.SpliceIn) fun LightningOutgoingPayment.outgoingPaymentFailure(): OutgoingPaymentFailure? { return (status as? LightningOutgoingPayment.Status.Completed.Failed)?.let { status -> @@ -134,214 +114,122 @@ fun LightningOutgoingPayment.explainAsFinalFailure(): FinalFailure? { } } -fun LightningOutgoingPayment.Details.asNormal(): LightningOutgoingPayment.Details.Normal? = when (this) { - is LightningOutgoingPayment.Details.Normal -> this - else -> null -} +fun LightningOutgoingPayment.Details.asNormal(): LightningOutgoingPayment.Details.Normal? = + (this as? LightningOutgoingPayment.Details.Normal) -fun LightningOutgoingPayment.Details.asSwapOut(): LightningOutgoingPayment.Details.SwapOut? = when (this) { - is LightningOutgoingPayment.Details.SwapOut -> this - else -> null -} +fun LightningOutgoingPayment.Details.asSwapOut(): LightningOutgoingPayment.Details.SwapOut? = + (this as? LightningOutgoingPayment.Details.SwapOut) -fun LightningOutgoingPayment.Status.asPending(): LightningOutgoingPayment.Status.Pending? = when (this) { - is LightningOutgoingPayment.Status.Pending -> this - else -> null -} +fun LightningOutgoingPayment.Status.asPending(): LightningOutgoingPayment.Status.Pending? = + (this as? LightningOutgoingPayment.Status.Pending) -fun LightningOutgoingPayment.Status.asFailed(): LightningOutgoingPayment.Status.Completed.Failed? = when (this) { - is LightningOutgoingPayment.Status.Completed.Failed -> this - else -> null -} +fun LightningOutgoingPayment.Status.asFailed(): LightningOutgoingPayment.Status.Completed.Failed? = + (this as? LightningOutgoingPayment.Status.Completed.Failed) -fun LightningOutgoingPayment.Status.asSucceeded(): LightningOutgoingPayment.Status.Completed.Succeeded? = when (this) { - is LightningOutgoingPayment.Status.Completed.Succeeded -> this - else -> null -} +fun LightningOutgoingPayment.Status.asSucceeded(): + LightningOutgoingPayment.Status.Completed.Succeeded? = + (this as? LightningOutgoingPayment.Status.Completed.Succeeded) -fun LightningOutgoingPayment.Status.asOffChain(): LightningOutgoingPayment.Status.Completed.Succeeded.OffChain? = when (this) { - is LightningOutgoingPayment.Status.Completed.Succeeded.OffChain -> this - else -> null -} +fun LightningOutgoingPayment.Status.asOffChain(): + LightningOutgoingPayment.Status.Completed.Succeeded.OffChain? = + (this as? LightningOutgoingPayment.Status.Completed.Succeeded.OffChain) -fun ChannelState.asOffline(): Offline? = when (this) { - is Offline -> this - else -> null -} -fun ChannelState.asClosing(): Closing? = when (this) { - is Closing -> this - else -> null -} -fun ChannelState.asClosed(): Closed? = when (this) { - is Closed -> this - else -> null -} -fun ChannelState.asAborted(): Aborted? = when (this) { - is Aborted -> this - else -> null -} +fun ChannelState.asOffline(): Offline? = (this as? Offline) +fun ChannelState.asClosing(): Closing? = (this as? Closing) +fun ChannelState.asClosed(): Closed? = (this as? Closed) +fun ChannelState.asAborted(): Aborted? = (this as? Aborted) -fun TcpSocket.TLS.asDisabled(): TcpSocket.TLS.DISABLED? = when (this) { - is TcpSocket.TLS.DISABLED -> this - else -> null -} +fun TcpSocket.TLS.asDisabled(): TcpSocket.TLS.DISABLED? = + (this as? TcpSocket.TLS.DISABLED) -fun TcpSocket.TLS.asTrustedCertificates(): TcpSocket.TLS.TRUSTED_CERTIFICATES? = when (this) { - is TcpSocket.TLS.TRUSTED_CERTIFICATES -> this - else -> null -} +fun TcpSocket.TLS.asTrustedCertificates(): TcpSocket.TLS.TRUSTED_CERTIFICATES? = + (this as? TcpSocket.TLS.TRUSTED_CERTIFICATES) -fun TcpSocket.TLS.asPinnedPublicKey(): TcpSocket.TLS.PINNED_PUBLIC_KEY? = when (this) { - is TcpSocket.TLS.PINNED_PUBLIC_KEY -> this - else -> null -} - -fun TcpSocket.TLS.asUnsafeCertificates(): TcpSocket.TLS.UNSAFE_CERTIFICATES? = when (this) { - is TcpSocket.TLS.UNSAFE_CERTIFICATES -> this - else -> null -} - -fun Connection.asClosed(): Connection.CLOSED? = when (this) { - is Connection.CLOSED -> this - else -> null -} +fun TcpSocket.TLS.asPinnedPublicKey(): TcpSocket.TLS.PINNED_PUBLIC_KEY? = + (this as? TcpSocket.TLS.PINNED_PUBLIC_KEY) -fun Connection.asEstablishing(): Connection.ESTABLISHING? = when (this) { - is Connection.ESTABLISHING -> this - else -> null -} +fun TcpSocket.TLS.asUnsafeCertificates(): TcpSocket.TLS.UNSAFE_CERTIFICATES? = + (this as? TcpSocket.TLS.UNSAFE_CERTIFICATES) -fun Connection.asEstablished(): Connection.ESTABLISHED? = when (this) { - is Connection.ESTABLISHED -> this - else -> null -} +fun Connection.asClosed(): Connection.CLOSED? = (this as? Connection.CLOSED) +fun Connection.asEstablishing(): Connection.ESTABLISHING? = (this as? Connection.ESTABLISHING) +fun Connection.asEstablished(): Connection.ESTABLISHED? = (this as? Connection.ESTABLISHED) -fun NativeSocketException.asPOSIX(): NativeSocketException.POSIX? = when (this) { - is NativeSocketException.POSIX -> this - else -> null -} +fun NativeSocketException.asPOSIX(): NativeSocketException.POSIX? = + (this as? NativeSocketException.POSIX) -fun NativeSocketException.asDNS(): NativeSocketException.DNS? = when (this) { - is NativeSocketException.DNS -> this - else -> null -} +fun NativeSocketException.asDNS(): NativeSocketException.DNS? = + (this as? NativeSocketException.DNS) -fun NativeSocketException.asTLS(): NativeSocketException.TLS? = when (this) { - is NativeSocketException.TLS -> this - else -> null -} +fun NativeSocketException.asTLS(): NativeSocketException.TLS? = + (this as? NativeSocketException.TLS) fun ElectrumMiniWallet.currentWalletState(): WalletState = this.walletStateFlow.value -fun NodeEvents.asChannelEvents(): ChannelEvents? = when (this) { - is ChannelEvents -> this - else -> null -} +fun NodeEvents.asChannelEvents(): ChannelEvents? = (this as? ChannelEvents) -fun ChannelEvents.asCreating(): ChannelEvents.Creating? = when (this) { - is ChannelEvents.Creating -> this - else -> null -} +fun ChannelEvents.asCreating(): ChannelEvents.Creating? = (this as? ChannelEvents.Creating) +fun ChannelEvents.asCreated(): ChannelEvents.Created? = (this as? ChannelEvents.Created) +fun ChannelEvents.asConfirmed(): ChannelEvents.Confirmed? = (this as? ChannelEvents.Confirmed) -fun ChannelEvents.asCreated(): ChannelEvents.Created? = when (this) { - is ChannelEvents.Created -> this - else -> null -} +fun LiquidityEvents.Rejected.Reason.asOverAbsoluteFee(): + LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee? = + (this as? LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee) -fun ChannelEvents.asConfirmed(): ChannelEvents.Confirmed? = when (this) { - is ChannelEvents.Confirmed -> this - else -> null -} +fun LiquidityEvents.Rejected.Reason.asOverRelativeFee(): + LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee? = + (this as? LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee) -fun LiquidityEvents.Rejected.Reason.asOverAbsoluteFee(): LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee? = when (this) { - is LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee -> this - else -> null -} +fun LiquidityPolicy.asDisable(): LiquidityPolicy.Disable? = (this as? LiquidityPolicy.Disable) +fun LiquidityPolicy.asAuto(): LiquidityPolicy.Auto? = (this as? LiquidityPolicy.Auto) -fun LiquidityEvents.Rejected.Reason.asOverRelativeFee(): LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee? = when (this) { - is LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee -> this - else -> null -} +fun ChannelFundingResponse.asSuccess(): ChannelFundingResponse.Success? = + (this as? ChannelFundingResponse.Success) -fun LiquidityPolicy.asDisable(): LiquidityPolicy.Disable? = when (this) { - is LiquidityPolicy.Disable -> this - else -> null -} +fun ChannelFundingResponse.asFailure(): ChannelFundingResponse.Failure? = + (this as? ChannelFundingResponse.Failure) -fun LiquidityPolicy.asAuto(): LiquidityPolicy.Auto? = when (this) { - is LiquidityPolicy.Auto -> this - else -> null -} +fun ChannelFundingResponse.Failure.asInsufficientFunds(): ChannelFundingResponse.Failure.InsufficientFunds? = + (this as? ChannelFundingResponse.Failure.InsufficientFunds) -fun ChannelCommand.Commitment.Splice.Response.asCreated(): ChannelCommand.Commitment.Splice.Response.Created? = when (this) { - is ChannelCommand.Commitment.Splice.Response.Created -> this - else -> null -} +fun ChannelFundingResponse.Failure.asInvalidSpliceOutPubKeyScript(): ChannelFundingResponse.Failure.InvalidSpliceOutPubKeyScript? = + (this as? ChannelFundingResponse.Failure.InvalidSpliceOutPubKeyScript) -fun ChannelCommand.Commitment.Splice.Response.asFailure(): ChannelCommand.Commitment.Splice.Response.Failure? = when (this) { - is ChannelCommand.Commitment.Splice.Response.Failure -> this - else -> null -} +fun ChannelFundingResponse.Failure.asSpliceAlreadyInProgress(): ChannelFundingResponse.Failure.SpliceAlreadyInProgress? = + (this as? ChannelFundingResponse.Failure.SpliceAlreadyInProgress) -fun ChannelCommand.Commitment.Splice.Response.Failure.asInsufficientFunds(): ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds? = when (this) { - is ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds -> this - else -> null -} +fun ChannelFundingResponse.Failure.asConcurrentRemoteSplice(): ChannelFundingResponse.Failure.ConcurrentRemoteSplice? = + (this as? ChannelFundingResponse.Failure.ConcurrentRemoteSplice) -fun ChannelCommand.Commitment.Splice.Response.Failure.asInvalidSpliceOutPubKeyScript(): ChannelCommand.Commitment.Splice.Response.Failure.InvalidSpliceOutPubKeyScript? = when (this) { - is ChannelCommand.Commitment.Splice.Response.Failure.InvalidSpliceOutPubKeyScript -> this - else -> null -} +fun ChannelFundingResponse.Failure.asChannelNotQuiescent(): ChannelFundingResponse.Failure.ChannelNotQuiescent? = + (this as? ChannelFundingResponse.Failure.ChannelNotQuiescent) -fun ChannelCommand.Commitment.Splice.Response.Failure.asSpliceAlreadyInProgress(): ChannelCommand.Commitment.Splice.Response.Failure.SpliceAlreadyInProgress? = when (this) { - is ChannelCommand.Commitment.Splice.Response.Failure.SpliceAlreadyInProgress -> this - else -> null -} +fun ChannelFundingResponse.Failure.asInvalidChannelParameters(): ChannelFundingResponse.Failure.InvalidChannelParameters? = + (this as? ChannelFundingResponse.Failure.InvalidChannelParameters) -fun ChannelCommand.Commitment.Splice.Response.Failure.asChannelNotQuiescent(): ChannelCommand.Commitment.Splice.Response.Failure.ChannelNotQuiescent? = when (this) { - is ChannelCommand.Commitment.Splice.Response.Failure.ChannelNotQuiescent -> this - else -> null -} +fun ChannelFundingResponse.Failure.asInvalidLiquidityAds(): ChannelFundingResponse.Failure.InvalidLiquidityAds? = + (this as? ChannelFundingResponse.Failure.InvalidLiquidityAds) -fun ChannelCommand.Commitment.Splice.Response.Failure.asConcurrentRemoteSplice(): ChannelCommand.Commitment.Splice.Response.Failure.ConcurrentRemoteSplice? = when (this) { - is ChannelCommand.Commitment.Splice.Response.Failure.ConcurrentRemoteSplice -> this - else -> null -} +fun ChannelFundingResponse.Failure.asFundingFailure(): ChannelFundingResponse.Failure.FundingFailure? = + (this as? ChannelFundingResponse.Failure.FundingFailure) -fun ChannelCommand.Commitment.Splice.Response.Failure.asInvalidLiquidityAds(): ChannelCommand.Commitment.Splice.Response.Failure.InvalidLiquidityAds? = when (this) { - is ChannelCommand.Commitment.Splice.Response.Failure.InvalidLiquidityAds -> this - else -> null -} +fun ChannelFundingResponse.Failure.asCannotStartSession(): ChannelFundingResponse.Failure.CannotStartSession? = + (this as? ChannelFundingResponse.Failure.CannotStartSession) -fun ChannelCommand.Commitment.Splice.Response.Failure.asFundingFailure(): ChannelCommand.Commitment.Splice.Response.Failure.FundingFailure? = when (this) { - is ChannelCommand.Commitment.Splice.Response.Failure.FundingFailure -> this - else -> null -} +fun ChannelFundingResponse.Failure.asInteractiveTxSessionFailed(): ChannelFundingResponse.Failure.InteractiveTxSessionFailed? = + (this as? ChannelFundingResponse.Failure.InteractiveTxSessionFailed) -fun ChannelCommand.Commitment.Splice.Response.Failure.asCannotStartSession(): ChannelCommand.Commitment.Splice.Response.Failure.CannotStartSession? = when (this) { - is ChannelCommand.Commitment.Splice.Response.Failure.CannotStartSession -> this - else -> null -} +fun ChannelFundingResponse.Failure.asCannotCreateCommitTx(): ChannelFundingResponse.Failure.CannotCreateCommitTx? = + (this as? ChannelFundingResponse.Failure.CannotCreateCommitTx) -fun ChannelCommand.Commitment.Splice.Response.Failure.asInteractiveTxSessionFailed(): ChannelCommand.Commitment.Splice.Response.Failure.InteractiveTxSessionFailed? = when (this) { - is ChannelCommand.Commitment.Splice.Response.Failure.InteractiveTxSessionFailed -> this - else -> null -} - -fun ChannelCommand.Commitment.Splice.Response.Failure.asCannotCreateCommitTx(): ChannelCommand.Commitment.Splice.Response.Failure.CannotCreateCommitTx? = when (this) { - is ChannelCommand.Commitment.Splice.Response.Failure.CannotCreateCommitTx -> this - else -> null -} +fun ChannelFundingResponse.Failure.asAbortedByPeer(): ChannelFundingResponse.Failure.AbortedByPeer? = + (this as? ChannelFundingResponse.Failure.AbortedByPeer) -fun ChannelCommand.Commitment.Splice.Response.Failure.asAbortedByPeer(): ChannelCommand.Commitment.Splice.Response.Failure.AbortedByPeer? = when (this) { - is ChannelCommand.Commitment.Splice.Response.Failure.AbortedByPeer -> this - else -> null -} +fun ChannelFundingResponse.Failure.asUnexpectedMessage(): ChannelFundingResponse.Failure.UnexpectedMessage? = + (this as? ChannelFundingResponse.Failure.UnexpectedMessage) -fun ChannelCommand.Commitment.Splice.Response.Failure.asDisconnected(): ChannelCommand.Commitment.Splice.Response.Failure.Disconnected? = when (this) { - is ChannelCommand.Commitment.Splice.Response.Failure.Disconnected -> this - else -> null -} +fun ChannelFundingResponse.Failure.asDisconnected(): ChannelFundingResponse.Failure.Disconnected? = + (this as? ChannelFundingResponse.Failure.Disconnected) suspend fun ElectrumClient.kotlin_getConfirmations(txid: TxId): Int? { return this.getConfirmations(txid) @@ -355,35 +243,24 @@ fun defaultSwapInParams(): SwapInParams { ) } -fun SensitiveTaskEvents.asTaskStarted(): SensitiveTaskEvents.TaskStarted? = when (this) { - is SensitiveTaskEvents.TaskStarted -> this - else -> null -} +fun SensitiveTaskEvents.asTaskStarted(): SensitiveTaskEvents.TaskStarted? = + (this as? SensitiveTaskEvents.TaskStarted) -fun SensitiveTaskEvents.asTaskEnded(): SensitiveTaskEvents.TaskEnded? = when (this) { - is SensitiveTaskEvents.TaskEnded -> this - else -> null -} +fun SensitiveTaskEvents.asTaskEnded(): SensitiveTaskEvents.TaskEnded? = + (this as? SensitiveTaskEvents.TaskEnded) -fun SensitiveTaskEvents.TaskIdentifier.asInteractiveTx(): SensitiveTaskEvents.TaskIdentifier.InteractiveTx? = when (this) { - is SensitiveTaskEvents.TaskIdentifier.InteractiveTx -> this - else -> null -} +fun SensitiveTaskEvents.TaskIdentifier.asInteractiveTx(): + SensitiveTaskEvents.TaskIdentifier.InteractiveTx? = + (this as? SensitiveTaskEvents.TaskIdentifier.InteractiveTx) -fun PeerEvent.asPaymentProgress(): PaymentProgress? = when (this) { - is PaymentProgress -> this - else -> null -} +fun PeerEvent.asPaymentProgress(): PaymentProgress? = + (this as? PaymentProgress) -fun PeerEvent.asPaymentSent(): PaymentSent? = when (this) { - is PaymentSent -> this - else -> null -} +fun PeerEvent.asPaymentSent(): PaymentSent? = + (this as? PaymentSent) -fun PeerEvent.asPaymentNotSent(): PaymentNotSent? = when (this) { - is PaymentNotSent -> this - else -> null -} +fun PeerEvent.asPaymentNotSent(): PaymentNotSent? = + (this as? PaymentNotSent) fun FinalFailure.asAlreadyPaid(): FinalFailure.AlreadyPaid? = (this as? FinalFailure.AlreadyPaid) @@ -485,132 +362,6 @@ fun NSData_copyTo(data: NSData, buffer: ByteArray, offset: Int = 0) = data.copyT fun ByteArray_toNSDataSlice(buffer: ByteArray, offset: Int, length: Int): NSData = buffer.toNSData(offset = offset, length = length) fun ByteArray_toNSData(buffer: ByteArray): NSData = buffer.toNSData() -/** - * The class LiquidityAds.LeaseRate is NOT exposed to iOS. - * That is, it's exposed via Objective-C, but cannot be mapped to Swift. - * The following error message is displayed in Xcode: - * - * > Imported declaration 'PhoenixSharedLightning_kmpLiquidityAdsLeaseRate' could - * > not be mapped to 'Lightning_kmpLiquidityAds.LeaseRate' - * - * The end result is that any function that uses this class as a parameter, - * or as a return value, is NOT available to Swift. - * - * The fix is to start using TouchLab's SKIE library, - * which (in my experience) fixes all these problems. - * But in the meantime, we're working around it by exposing our own class & wrapper functions. - */ -data class LiquidityAds_LeaseRate( - val leaseDuration: Int, - val fundingWeight: Int, - val leaseFeeProportional: Int, - val leaseFeeBase: Satoshi, - val maxRelayFeeProportional: Int, - val maxRelayFeeBase: MilliSatoshi -) { - constructor(src: LiquidityAds.LeaseRate) : this( - leaseDuration = src.leaseDuration, - fundingWeight = src.fundingWeight, - leaseFeeProportional = src.leaseFeeProportional, - leaseFeeBase = src.leaseFeeBase, - maxRelayFeeProportional = src.maxRelayFeeProportional, - maxRelayFeeBase = src.maxRelayFeeBase - ) - fun unwrap() = LiquidityAds.LeaseRate( - leaseDuration = this.leaseDuration, - fundingWeight = this.fundingWeight, - leaseFeeProportional = this.leaseFeeProportional, - leaseFeeBase = this.leaseFeeBase, - maxRelayFeeProportional = this.maxRelayFeeProportional, - maxRelayFeeBase = this.maxRelayFeeBase - ) -} - -data class LiquidityAds_LeaseFees( - val miningFee: Satoshi, - val serviceFee: Satoshi -) { - constructor(src: LiquidityAds.LeaseFees) : this( - miningFee = src.miningFee, - serviceFee = src.serviceFee - ) - fun unwrap() = LiquidityAds.LeaseFees( - miningFee = this.miningFee, - serviceFee = this.serviceFee - ) - - val total: Satoshi = unwrap().total -} - -data class LiquidityAds_LeaseWitness( - val fundingScript: ByteVector, - val leaseDuration: Int, - val leaseEnd: Int, - val maxRelayFeeProportional: Int, - val maxRelayFeeBase: MilliSatoshi -) { - constructor(src: LiquidityAds.LeaseWitness) : this( - fundingScript = src.fundingScript, - leaseDuration = src.leaseDuration, - leaseEnd = src.leaseEnd, - maxRelayFeeProportional = src.maxRelayFeeProportional, - maxRelayFeeBase = src.maxRelayFeeBase - ) - fun unwrap() = LiquidityAds.LeaseWitness( - fundingScript = this.fundingScript, - leaseDuration = this.leaseDuration, - leaseEnd = this.leaseEnd, - maxRelayFeeProportional = this.maxRelayFeeProportional, - maxRelayFeeBase = this.maxRelayFeeBase - ) - - fun sign(nodeKey: PrivateKey): ByteVector64 = unwrap().sign(nodeKey) - fun verify(nodeId: PublicKey, sig: ByteVector64): Boolean = unwrap().verify(nodeId, sig) - fun encode(): ByteArray = unwrap().encode() -} - -data class LiquidityAds_Lease( - val amount: Satoshi, - val fees: LiquidityAds_LeaseFees, - val sellerSig: ByteVector64, - val witness: LiquidityAds_LeaseWitness -) { - constructor(src: LiquidityAds.Lease) : this( - amount = src.amount, - fees = LiquidityAds_LeaseFees(src.fees), - sellerSig = src.sellerSig, - witness = LiquidityAds_LeaseWitness(src.witness) - ) - fun unwrap() = LiquidityAds.Lease( - amount = this.amount, - fees = this.fees.unwrap(), - sellerSig = this.sellerSig, - witness = this.witness.unwrap() - ) - - val start: Int = unwrap().start - val expiry: Int = unwrap().expiry -} - -suspend fun Peer._estimateFeeForInboundLiquidity( - amount: Satoshi, - targetFeerate: FeeratePerKw, - leaseRate: LiquidityAds_LeaseRate -): Pair? { - return this.estimateFeeForInboundLiquidity(amount, targetFeerate, leaseRate.unwrap()) -} - -suspend fun Peer._requestInboundLiquidity( - amount: Satoshi, - feerate: FeeratePerKw, - leaseRate: LiquidityAds_LeaseRate -): ChannelCommand.Commitment.Splice.Response? { - return this.requestInboundLiquidity(amount, feerate, leaseRate.unwrap()) -} - -val InboundLiquidityOutgoingPayment._lease: LiquidityAds_Lease - get() = LiquidityAds_Lease(this.lease) - fun WalletState.WalletWithConfirmations._spendExpiredSwapIn( swapInKeys: KeyManager.SwapInOnChainKeys, scriptPubKey: ByteVector, @@ -619,6 +370,10 @@ fun WalletState.WalletWithConfirmations._spendExpiredSwapIn( return this.spendExpiredSwapIn(swapInKeys, scriptPubKey, feerate) } +suspend fun Peer.fundingRate(amount: Satoshi): LiquidityAds.FundingRate? { + return this.remoteFundingRates.filterNotNull().first().findRate(amount) +} + suspend fun Peer.altPayOffer( paymentId: UUID, amount: MilliSatoshi, @@ -662,12 +417,3 @@ suspend fun Peer.betterPayOffer( send(PayOffer(paymentId, payerKey, payerNote, amount, offer, fetchInvoiceTimeoutInSeconds.seconds)) return res.await() } - -fun LocalKeyManager.cloudHash(name: String): String { - val nid: ByteArray = this.nodeKeys.nodeKey.publicKey.value.toByteArray() - val ck: ByteArray = this.cloudKey().toByteArray() - val nm: ByteArray = name.toByteArray() - - val input = nid.concat(ck).concat(nm) - return Crypto.hash160(input).byteVector().toHex() -} diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/PhoenixExposure.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/PhoenixExposure.kt index 54e5d721a..a64b72bb9 100644 --- a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/PhoenixExposure.kt +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/PhoenixExposure.kt @@ -27,10 +27,10 @@ fun WalletPaymentOrderRow.kotlinId(): WalletPaymentId { return this.id } -fun NodeParamsManager.Companion._liquidityLeaseRate(amount: Satoshi): LiquidityAds_LeaseRate { - val result = this.liquidityLeaseRate(amount) - return LiquidityAds_LeaseRate(result) -} +//fun NodeParamsManager.Companion._liquidityLeaseRate(amount: Satoshi): LiquidityAds_LeaseRate { +// val result = this.liquidityLeaseRate(amount) +// return LiquidityAds_LeaseRate(result) +//} fun LocalChannelInfo.Companion.availableForReceive( channels: List