diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 058de6142..3cf028a18 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -1,6 +1,6 @@ object Versions { - const val lightningKmp = "1.6-SNAPSHOT" - const val secp256k1 = "0.13.0" + const val lightningKmp = "1.6.1" + const val secp256k1 = "0.14.0" const val torMobile = "0.2.0" const val kotlin = "1.9.22" @@ -15,12 +15,12 @@ object Versions { object Android { const val coreKtx = "1.9.0" - const val lifecycle = "2.7.0" + const val lifecycle = "2.6.0" const val prefs = "1.2.0" const val datastore = "1.0.0" - const val compose = "1.6.1" + const val compose = "1.6.2" const val composeCompiler = "1.5.8" - const val navCompose = "2.7.6" + const val navCompose = "2.6.0" const val accompanist = "0.30.1" const val composeConstraintLayout = "1.1.0-alpha09" const val biometrics = "1.1.0" @@ -36,7 +36,7 @@ object Versions { object AndroidLegacy { const val eclair = "0.4.23-android-phoenix" - const val safeArgs = "2.7.6" + const val safeArgs = "2.6.0" const val appCompat = "1.3.0" const val material = "1.7.0" const val navigation = "2.4.2" diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt index 1cf8fe488..dec0fc28b 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt @@ -81,8 +81,9 @@ import fr.acinq.phoenix.android.settings.fees.AdvancedIncomingFeePolicy import fr.acinq.phoenix.android.settings.fees.LiquidityPolicyView import fr.acinq.phoenix.android.payments.liquidity.RequestLiquidityView import fr.acinq.phoenix.android.settings.walletinfo.FinalWalletInfo +import fr.acinq.phoenix.android.settings.walletinfo.SwapInAddresses import fr.acinq.phoenix.android.settings.walletinfo.SwapInSignerView -import fr.acinq.phoenix.android.settings.walletinfo.SwapInWalletInfo +import fr.acinq.phoenix.android.settings.walletinfo.SwapInWallet import fr.acinq.phoenix.android.settings.walletinfo.WalletInfoView import fr.acinq.phoenix.android.startup.LegacySwitcherView import fr.acinq.phoenix.android.startup.StartupView @@ -165,7 +166,10 @@ fun AppView( .fillMaxHeight() ) { - NavHost(navController = navController, startDestination = "${Screen.Startup.route}?next={next}") { + NavHost( + navController = navController, + startDestination = "${Screen.Startup.route}?next={next}", + ) { composable( route = "${Screen.Startup.route}?next={next}", arguments = listOf( @@ -390,6 +394,7 @@ fun AppView( onBackClick = { navController.popBackStack() }, onLightningWalletClick = { navController.navigate(Screen.Channels.route) }, onSwapInWalletClick = { navController.navigate(Screen.WalletInfo.SwapInWallet.route) }, + onSwapInWalletInfoClick = { navController.navigate(Screen.WalletInfo.SwapInAddresses.route) }, onFinalWalletClick = { navController.navigate(Screen.WalletInfo.FinalWallet.route) }, ) } @@ -399,12 +404,15 @@ fun AppView( navDeepLink { uriPattern = "phoenix:swapinwallet" } ) ) { - SwapInWalletInfo( + SwapInWallet( onBackClick = { navController.popBackStack() }, onViewChannelPolicyClick = { navController.navigate(Screen.LiquidityPolicy.route) }, onAdvancedClick = { navController.navigate(Screen.WalletInfo.SwapInSigner.route) }, ) } + composable(Screen.WalletInfo.SwapInAddresses.route) { + SwapInAddresses(onBackClick = { navController.popBackStack() }) + } composable(Screen.WalletInfo.SwapInSigner.route) { SwapInSignerView(onBackClick = { navController.popBackStack() }) } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/Navigation.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/Navigation.kt index 521d05bd4..9b7184a2b 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/Navigation.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/Navigation.kt @@ -56,6 +56,7 @@ sealed class Screen(val route: String) { object Logs : Screen("settings/logs") object WalletInfo : Screen("settings/walletinfo") { object SwapInWallet: Screen("settings/walletinfo/swapin") + object SwapInAddresses: Screen("settings/walletinfo/swapinaddresses") object SwapInSigner: Screen("settings/walletinfo/swapinsigner") object FinalWallet: Screen("settings/walletinfo/final") } 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 ef6a8d85a..f3f893b10 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 @@ -400,6 +400,23 @@ fun WebLink( ) } +@Composable +fun AddressLinkButton( + modifier: Modifier = Modifier, + address: String, +) { + WebLink( + text = address, + url = addressUrl(address = address), + space = 4.dp, + maxLines = 1, + fontSize = 15.sp, + iconSize = 14.dp, + onClickLabel = stringResource(id = R.string.accessibility_address_explorer_link), + modifier = modifier, + ) +} + @Composable fun TransactionLinkButton( modifier: Modifier = Modifier, @@ -422,6 +439,11 @@ fun txUrl(txId: TxId): String { return business.blockchainExplorer.txUrl(txId = txId, website = BlockchainExplorer.Website.MempoolSpace) } +@Composable +fun addressUrl(address: String): String { + return business.blockchainExplorer.addressUrl(addr = address, website = BlockchainExplorer.Website.MempoolSpace) +} + fun openLink(context: Context, link: String) { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link))) } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/MenuButton.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/MenuButton.kt new file mode 100644 index 000000000..04eb49ac4 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/MenuButton.kt @@ -0,0 +1,104 @@ +/* + * 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.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import fr.acinq.phoenix.android.R + + +@Composable +fun MenuButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.body1, + icon: Int? = null, + iconTint: Color = MaterialTheme.colors.onSurface, + iconSpace: Dp = 12.dp, + padding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 16.dp), + enabled: Boolean = true, +) { + MenuButton( + content = { + Text( + text = text, + style = textStyle, + textAlign = TextAlign.Start + ) + }, + onClick = onClick, + modifier = modifier, + icon = icon, + iconTint = iconTint, + iconSpace = iconSpace, + padding = padding, + enabled = enabled, + ) +} + +@Composable +fun MenuButton( + content: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + onClickLabel: String? = null, + icon: Int? = null, + iconTint: Color = MaterialTheme.colors.onSurface, + iconSpace: Dp = 12.dp, + padding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 16.dp), + enabled: Boolean = true, +) { + Row( + modifier = modifier + .clickable( + onClick = onClick, + enabled = enabled, + role = Role.Button, + onClickLabel = onClickLabel, + ) + .padding(padding), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) { + PhoenixIcon(resourceId = icon, tint = iconTint) + Spacer(modifier = Modifier.width(iconSpace)) + } + Box(modifier = Modifier.weight(1f)) { + content() + } + PhoenixIcon(resourceId = R.drawable.ic_chevron_right, tint = MaterialTheme.typography.caption.color.copy(alpha = 0.6f)) + } +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Preferences.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Preferences.kt index 65affc403..d3b93f02e 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Preferences.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Preferences.kt @@ -117,14 +117,13 @@ private fun PreferenceDialogItem( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 3.dp), - verticalAlignment = Alignment.CenterVertically + .padding(horizontal = 8.dp), ) { RadioButton(selected = selected, onClick = { onClick(item) }) - Spacer(modifier = Modifier.width(2.dp)) - Column { + Column(modifier = Modifier.padding(vertical = 12.dp)) { Text(text = item.title) item.description?.let { + Spacer(modifier = Modifier.height(4.dp)) Text(text = it, style = MaterialTheme.typography.subtitle2) } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Settings.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Settings.kt index f16c78908..87bd668df 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Settings.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Settings.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -99,6 +100,7 @@ fun SettingWithCopy( } } +/** Static setting with composable description, and decoration to the left of the title. */ @Composable fun SettingWithDecoration( modifier: Modifier = Modifier, @@ -162,7 +164,7 @@ fun SettingInteractive( Column( modifier .fillMaxWidth() - .clickable(onClick = { if (enabled) onClick() }) + .clickable(onClickLabel = title, role = Role.Button, onClick = { if (enabled) onClick() }) .enableOrFade(enabled) .padding(horizontal = 16.dp, vertical = 12.dp), ) { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/ScanDataView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/ScanDataView.kt index cd1847cf0..68dadb776 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/ScanDataView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/ScanDataView.kt @@ -108,15 +108,15 @@ fun ScanDataView( onScannedText = { postIntent(Scan.Intent.Parse(request = it)) } ) } - is Scan.Model.InvoiceFlow.InvoiceRequest -> { - SendLightningPaymentView( - paymentRequest = model.paymentRequest, + is Scan.Model.Bolt11InvoiceFlow.Bolt11InvoiceRequest -> { + SendBolt11PaymentView( + invoice = model.invoice, trampolineFees = trampolineFees, onBackClick = onBackClick, onPayClick = { postIntent(it) } ) } - Scan.Model.InvoiceFlow.Sending -> { + Scan.Model.Bolt11InvoiceFlow.Sending -> { LaunchedEffect(key1 = Unit) { onBackClick() } } is Scan.Model.OnchainFlow -> { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/SendLightningView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/SendLightningView.kt index 3e0c350c4..a82c0006f 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/SendLightningView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/SendLightningView.kt @@ -31,7 +31,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import fr.acinq.lightning.TrampolineFees -import fr.acinq.lightning.payment.PaymentRequest +import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.phoenix.android.LocalBitcoinUnit import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.business @@ -42,17 +42,17 @@ import fr.acinq.phoenix.controllers.payments.Scan import fr.acinq.phoenix.utils.extensions.isAmountlessTrampoline @Composable -fun SendLightningPaymentView( - paymentRequest: PaymentRequest, +fun SendBolt11PaymentView( + invoice: Bolt11Invoice, trampolineFees: TrampolineFees?, onBackClick: () -> Unit, - onPayClick: (Scan.Intent.InvoiceFlow.SendInvoicePayment) -> Unit + onPayClick: (Scan.Intent.Bolt11InvoiceFlow.SendBolt11Invoice) -> Unit ) { val context = LocalContext.current val balance = business.balanceManager.balance.collectAsState(null).value val prefBitcoinUnit = LocalBitcoinUnit.current - val requestedAmount = paymentRequest.amount + val requestedAmount = invoice.amount var amount by remember { mutableStateOf(requestedAmount) } var amountErrorMessage by remember { mutableStateOf("") } @@ -88,17 +88,17 @@ fun SendLightningPaymentView( ) } ) { - paymentRequest.description?.takeIf { it.isNotBlank() }?.let { + invoice.description?.takeIf { it.isNotBlank() }?.let { SplashLabelRow(label = stringResource(R.string.send_description_label)) { Text(text = it) } } SplashLabelRow(label = stringResource(R.string.send_destination_label), icon = R.drawable.ic_zap) { SelectionContainer { - Text(text = paymentRequest.nodeId.toHex(), maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(text = invoice.nodeId.toHex(), maxLines = 2, overflow = TextOverflow.Ellipsis) } } - if (paymentRequest.isAmountlessTrampoline()) { + if (invoice.isAmountlessTrampoline()) { SplashLabelRow(label = "", helpMessage = stringResource(id = R.string.send_trampoline_amountless_warning_details)) { Text(text = stringResource(id = R.string.send_trampoline_amountless_warning_label)) } @@ -123,7 +123,7 @@ fun SendLightningPaymentView( enabled = mayDoPayments && amount != null && amountErrorMessage.isBlank() && trampolineFees != null, ) { safeLet(amount, trampolineFees) { amt, fees -> - onPayClick(Scan.Intent.InvoiceFlow.SendInvoicePayment(paymentRequest = paymentRequest, amount = amt, trampolineFees = fees)) + onPayClick(Scan.Intent.Bolt11InvoiceFlow.SendBolt11Invoice(invoice = invoice, amount = amt, trampolineFees = fees)) } } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsSplashView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsSplashView.kt index eb63d5db7..d10e3d937 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsSplashView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/details/PaymentDetailsSplashView.kt @@ -62,7 +62,6 @@ 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.errorMessage import fr.acinq.phoenix.utils.extensions.minDepthForFunding import fr.acinq.phoenix.utils.extensions.state import io.ktor.http.Url 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 76df34af6..65f56d99f 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 @@ -30,7 +30,8 @@ import androidx.compose.ui.unit.dp import fr.acinq.bitcoin.ByteVector32 import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.db.* -import fr.acinq.lightning.payment.PaymentRequest +import fr.acinq.lightning.payment.Bolt11Invoice +import fr.acinq.lightning.payment.Bolt12Invoice import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sum @@ -299,7 +300,12 @@ private fun DetailsForLightningOutgoingPayment( // -- details of the payment when (details) { is LightningOutgoingPayment.Details.Normal -> { - InvoiceSection(paymentRequest = details.paymentRequest) + when (val paymentRequest = details.paymentRequest) { + is Bolt11Invoice -> InvoiceSection(invoice = paymentRequest) + is Bolt12Invoice -> { + // TODO + } + } } is LightningOutgoingPayment.Details.SwapOut -> { TechnicalRowSelectable(label = stringResource(id = R.string.paymentdetails_bitcoin_address_label), value = details.address) @@ -402,7 +408,7 @@ private fun DetailsForIncoming( // -- details about the origin of the payment when (val origin = payment.origin) { is IncomingPayment.Origin.Invoice -> { - InvoiceSection(paymentRequest = origin.paymentRequest) + InvoiceSection(invoice = origin.paymentRequest) TechnicalRowSelectable(label = stringResource(id = R.string.paymentdetails_preimage_label), value = payment.preimage.toHex()) } is IncomingPayment.Origin.SwapIn -> { @@ -502,9 +508,9 @@ private fun LightningPart( @Composable private fun InvoiceSection( - paymentRequest: PaymentRequest + invoice: Bolt11Invoice ) { - val requestedAmount = paymentRequest.amount + val requestedAmount = invoice.amount if (requestedAmount != null) { TechnicalRowAmount( label = stringResource(id = R.string.paymentdetails_invoice_requested_label), @@ -513,15 +519,15 @@ private fun InvoiceSection( ) } - val description = (paymentRequest.description ?: paymentRequest.descriptionHash?.toHex())?.takeIf { it.isNotBlank() } + val description = (invoice.description ?: invoice.descriptionHash?.toHex())?.takeIf { it.isNotBlank() } if (description != null) { TechnicalRow(label = stringResource(id = R.string.paymentdetails_payment_request_description_label)) { Text(text = description) } } - TechnicalRowSelectable(label = stringResource(id = R.string.paymentdetails_payment_hash_label), value = paymentRequest.paymentHash.toHex()) - TechnicalRowSelectable(label = stringResource(id = R.string.paymentdetails_payment_request_label), value = paymentRequest.write()) + TechnicalRowSelectable(label = stringResource(id = R.string.paymentdetails_payment_hash_label), value = invoice.paymentHash.toHex()) + TechnicalRowSelectable(label = stringResource(id = R.string.paymentdetails_payment_request_label), value = invoice.write()) } // ============== utility components for this view 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 8ed427ffe..5f85d2bd3 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 @@ -87,14 +87,12 @@ class CsvExportViewModel( }) { if (state is CsvExportState.Generating) return@launch if (startTimestampMillis == null) throw IllegalArgumentException("start timestamp is undefined") - val swapInAddress = peerManager.getPeer().swapInAddress state = CsvExportState.Generating(exportedCount = 0) val csvConfig = CsvWriter.Configuration( includesFiat = includesFiat, includesDescription = includesDescription, includesNotes = includesNotes, includesOriginDestination = includesOriginDestination, - swapInAddress = swapInAddress ) log.debug("exporting payments data between start=${startTimestampMillis?.toAbsoluteDateTimeString()} end=${endTimestampMillis.toAbsoluteDateTimeString()} config=$csvConfig") val batchSize = 32 diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveBaseView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveBaseView.kt index c9e0f9efc..1cbb6f700 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveBaseView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveBaseView.kt @@ -96,22 +96,22 @@ fun ReceiveView( onScanDataClick: () -> Unit, ) { val context = LocalContext.current - val balanceManager = business.balanceManager - val vm: ReceiveViewModel = viewModel(factory = ReceiveViewModel.Factory(business.peerManager)) +// val balanceManager = business.balanceManager + val vm: ReceiveViewModel = viewModel(factory = ReceiveViewModel.Factory(business.chain, business.peerManager, business.walletManager)) val defaultInvoiceExpiry by UserPrefs.getInvoiceDefaultExpiry(context).collectAsState(null) val defaultInvoiceDesc by UserPrefs.getInvoiceDefaultDesc(context).collectAsState(null) - // When a on-chain payment has been received, go back to the home screen (via the onSwapInReceived callback) - LaunchedEffect(key1 = Unit) { - var previousBalance: WalletBalance = WalletBalance.empty() - balanceManager.swapInWalletBalance.collect { - if (previousBalance != WalletBalance.empty() && it.total > 0.sat && it != previousBalance) { - onSwapInReceived() - } else { - previousBalance = it - } - } - } +// // When a on-chain payment has been received, go back to the home screen (via the onSwapInReceived callback) +// LaunchedEffect(key1 = Unit) { +// var previousBalance: WalletBalance = WalletBalance.empty() +// balanceManager.swapInWalletBalance.collect { +// if (previousBalance != WalletBalance.empty() && it.total > 0.sat && it != previousBalance) { +// onSwapInReceived() +// } else { +// previousBalance = it +// } +// } +// } DefaultScreenLayout(horizontalAlignment = Alignment.CenterHorizontally, isScrollable = true) { DefaultScreenHeader( @@ -175,7 +175,7 @@ private fun ReceiveViewPages( ) { when (index) { 0 -> LightningInvoiceView(vm = vm, onFeeManagementClick = onFeeManagementClick, onScanDataClick = onScanDataClick, defaultDescription = defaultInvoiceDescription, expiry = defaultInvoiceExpiry, maxWidth = maxWidth) - 1 -> BitcoinAddressView(state = vm.bitcoinAddressState, maxWidth = maxWidth) + 1 -> BitcoinAddressView(vm = vm, maxWidth = maxWidth) } } } @@ -184,11 +184,11 @@ private fun ReceiveViewPages( @Composable fun InvoiceHeader( - label: String, + icon: Int, helpMessage: String, - icon: Int + content: @Composable RowScope.() -> Unit, ) { - Row { + Row(verticalAlignment = Alignment.CenterVertically) { IconPopup( icon = icon, iconSize = 24.dp, @@ -198,66 +198,11 @@ fun InvoiceHeader( spaceLeft = null, popupMessage = helpMessage ) - Text(text = label) + content() } Spacer(modifier = Modifier.height(16.dp)) } - - -@Composable -private fun BitcoinAddressView( - state: ReceiveViewModel.BitcoinAddressState, - maxWidth: Dp -) { - val context = LocalContext.current - - InvoiceHeader( - label = stringResource(id = R.string.receive_bitcoin_title), - helpMessage = stringResource(id = R.string.receive_bitcoin_help), - icon = R.drawable.ic_chain - ) - - when (state) { - is ReceiveViewModel.BitcoinAddressState.Init -> { - QRCodeView(data = null, bitmap = null, maxWidth = maxWidth) - Spacer(modifier = Modifier.height(32.dp)) - CopyShareEditButtons(onCopy = { }, onShare = { }, onEdit = null, maxWidth = maxWidth) - } - is ReceiveViewModel.BitcoinAddressState.Show -> { - QRCodeView(data = state.address, bitmap = state.image, maxWidth = maxWidth) - Spacer(modifier = Modifier.height(32.dp)) - CopyShareEditButtons( - onCopy = { copyToClipboard(context, data = state.address) }, - onShare = { share(context, "bitcoin:${state.address}", context.getString(R.string.receive_bitcoin_share_subject), context.getString(R.string.receive_bitcoin_share_title)) }, - onEdit = null, - maxWidth = maxWidth, - ) - Spacer(modifier = Modifier.height(24.dp)) - HSeparator(width = 50.dp) - Spacer(modifier = Modifier.height(16.dp)) - QRCodeDetail(label = stringResource(id = R.string.receive_bitcoin_address_label), value = state.address) - - val isFromLegacy by LegacyPrefsDatastore.hasMigratedFromLegacy(context).collectAsState(initial = false) - if (isFromLegacy) { - Spacer(modifier = Modifier.height(24.dp)) - InfoMessage( - header = stringResource(id = R.string.receive_onchain_legacy_warning_title), - details = stringResource(id = R.string.receive_onchain_legacy_warning), - detailsStyle = MaterialTheme.typography.subtitle2, - alignment = Alignment.CenterHorizontally - ) - } - } - is ReceiveViewModel.BitcoinAddressState.Error -> { - ErrorMessage( - header = stringResource(id = R.string.receive_bitcoin_error), - details = state.e.localizedMessage - ) - } - } -} - @Composable fun QRCodeView( data: String?, diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveLightningView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveLightningView.kt index af6d9bd5b..717ad2479 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveLightningView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveLightningView.kt @@ -62,6 +62,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.db.IncomingPayment +import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.payment.LiquidityPolicy import fr.acinq.lightning.payment.PaymentRequest import fr.acinq.lightning.utils.msat @@ -111,7 +112,7 @@ fun LightningInvoiceView( LaunchedEffect(key1 = Unit) { paymentsManager.lastCompletedPayment.collect { val state = vm.lightningInvoiceState - if (state is ReceiveViewModel.LightningInvoiceState.Show && it is IncomingPayment && state.paymentRequest.paymentHash == it.paymentHash) { + if (state is LightningInvoiceState.Show && it is IncomingPayment && state.invoice.paymentHash == it.paymentHash) { vm.generateInvoice(amount = customAmount, description = customDesc, expirySeconds = expiry) } } @@ -120,12 +121,11 @@ fun LightningInvoiceView( val onEdit = { vm.isEditingLightningInvoice = true } InvoiceHeader( - label = stringResource(id = R.string.receive_lightning_title), + icon = R.drawable.ic_zap, helpMessage = stringResource(id = R.string.receive_lightning_help), - icon = R.drawable.ic_zap + content = { Text(text = stringResource(id = R.string.receive_lightning_title)) }, ) - val navController = navController val state = vm.lightningInvoiceState val isEditing = vm.isEditingLightningInvoice @@ -141,8 +141,8 @@ fun LightningInvoiceView( onFeeManagementClick = onFeeManagementClick, ) } - state is ReceiveViewModel.LightningInvoiceState.Init || state is ReceiveViewModel.LightningInvoiceState.Generating -> { - if (state is ReceiveViewModel.LightningInvoiceState.Init) { + state is LightningInvoiceState.Init || state is LightningInvoiceState.Generating -> { + if (state is LightningInvoiceState.Init) { LaunchedEffect(key1 = Unit) { vm.generateInvoice(amount = customAmount, description = customDesc, expirySeconds = expiry) } @@ -154,16 +154,16 @@ fun LightningInvoiceView( Spacer(modifier = Modifier.height(32.dp)) CopyShareEditButtons(onCopy = { }, onShare = { }, onEdit = onEdit, maxWidth = maxWidth) } - state is ReceiveViewModel.LightningInvoiceState.Show -> { + state is LightningInvoiceState.Show -> { DisplayLightningInvoice( - paymentRequest = state.paymentRequest, + invoice = state.invoice, bitmap = vm.lightningQRBitmap, onFeeManagementClick = onFeeManagementClick, onEdit = onEdit, maxWidth = maxWidth, ) } - state is ReceiveViewModel.LightningInvoiceState.Error -> { + state is LightningInvoiceState.Error -> { ErrorMessage( header = stringResource(id = R.string.receive_lightning_error), details = state.e.localizedMessage @@ -171,7 +171,7 @@ fun LightningInvoiceView( } } - if ((state is ReceiveViewModel.LightningInvoiceState.Init || state is ReceiveViewModel.LightningInvoiceState.Show) && !isEditing) { + if ((state is LightningInvoiceState.Init || state is LightningInvoiceState.Show) && !isEditing) { Spacer(modifier = Modifier.height(24.dp)) HSeparator(width = 50.dp) Spacer(modifier = Modifier.height(24.dp)) @@ -185,16 +185,16 @@ fun LightningInvoiceView( @Composable private fun DisplayLightningInvoice( - paymentRequest: PaymentRequest, + invoice: Bolt11Invoice, bitmap: ImageBitmap?, onEdit: () -> Unit, onFeeManagementClick: () -> Unit, maxWidth: Dp, ) { val context = LocalContext.current - val prString = remember(paymentRequest) { paymentRequest.write() } - val amount = paymentRequest.amount - val description = paymentRequest.description.takeUnless { it.isNullOrBlank() } + val prString = remember(invoice) { invoice.write() } + val amount = invoice.amount + val description = invoice.description.takeUnless { it.isNullOrBlank() } QRCodeView(data = prString, bitmap = bitmap, maxWidth = maxWidth) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveOnChainView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveOnChainView.kt new file mode 100644 index 000000000..136efd0c3 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveOnChainView.kt @@ -0,0 +1,98 @@ +/* + * 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.receive + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +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.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.Card +import fr.acinq.phoenix.android.components.HSeparator +import fr.acinq.phoenix.android.components.ProgressView +import fr.acinq.phoenix.android.components.feedback.ErrorMessage +import fr.acinq.phoenix.android.components.feedback.InfoMessage +import fr.acinq.phoenix.android.utils.copyToClipboard +import fr.acinq.phoenix.android.utils.share +import fr.acinq.phoenix.legacy.utils.LegacyPrefsDatastore + +@Composable +fun BitcoinAddressView( + vm: ReceiveViewModel, + maxWidth: Dp +) { + val context = LocalContext.current + + InvoiceHeader( + icon = R.drawable.ic_chain, + helpMessage = stringResource(id = R.string.receive_bitcoin_help), + content = { Text(text = stringResource(id = R.string.receive_bitcoin_title)) }, + ) + + when (val state = vm.currentSwapAddress) { + is BitcoinAddressState.Init -> { + Box(contentAlignment = Alignment.Center) { + QRCodeView(bitmap = null, data = null, maxWidth = maxWidth) + Card(shape = RoundedCornerShape(16.dp)) { ProgressView(text = stringResource(id = R.string.receive_bitcoin_generating)) } + } + Spacer(modifier = Modifier.height(32.dp)) + CopyShareEditButtons(onCopy = { }, onShare = { }, onEdit = null, maxWidth = maxWidth) + } + is BitcoinAddressState.Show -> { + QRCodeView(data = state.currentAddress, bitmap = state.image, maxWidth = maxWidth) + Spacer(modifier = Modifier.height(32.dp)) + CopyShareEditButtons( + onCopy = { copyToClipboard(context, data = state.currentAddress) }, + onShare = { share(context, "bitcoin:${state.currentAddress}", context.getString(R.string.receive_bitcoin_share_subject), context.getString(R.string.receive_bitcoin_share_title)) }, + onEdit = null, + maxWidth = maxWidth, + ) + Spacer(modifier = Modifier.height(24.dp)) + HSeparator(width = 50.dp) + Spacer(modifier = Modifier.height(16.dp)) + QRCodeDetail(label = stringResource(id = R.string.receive_bitcoin_address_label), value = state.currentAddress) + + val isFromLegacy by LegacyPrefsDatastore.hasMigratedFromLegacy(context).collectAsState(initial = false) + if (isFromLegacy) { + Spacer(modifier = Modifier.height(24.dp)) + InfoMessage( + header = stringResource(id = R.string.receive_onchain_legacy_warning_title), + details = stringResource(id = R.string.receive_onchain_legacy_warning), + detailsStyle = MaterialTheme.typography.subtitle2, + alignment = Alignment.CenterHorizontally + ) + } + } + is BitcoinAddressState.Error -> { + ErrorMessage( + header = stringResource(id = R.string.receive_bitcoin_error), + details = state.e.message + ) + } + } +} 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 653921533..64fe11374 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 @@ -16,6 +16,7 @@ package fr.acinq.phoenix.android.payments.receive +import android.content.Context import androidx.annotation.UiThread import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -25,37 +26,50 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.CreationExtras +import fr.acinq.bitcoin.Bitcoin +import fr.acinq.bitcoin.Chain +import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.Lightning import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.payment.PaymentRequest -import fr.acinq.lightning.utils.Either +import fr.acinq.lightning.payment.Bolt11Invoice +import fr.acinq.phoenix.android.PhoenixApplication import fr.acinq.phoenix.android.utils.BitmapHelper +import fr.acinq.phoenix.android.utils.datastore.InternalDataRepository +import fr.acinq.phoenix.android.utils.datastore.SwapAddressFormat +import fr.acinq.phoenix.android.utils.datastore.UserPrefs import fr.acinq.phoenix.managers.PeerManager +import fr.acinq.phoenix.managers.WalletManager import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.slf4j.LoggerFactory +sealed class LightningInvoiceState { + object Init : LightningInvoiceState() + object Generating : LightningInvoiceState() + data class Show(val invoice: Bolt11Invoice) : LightningInvoiceState() + data class Error(val e: Throwable) : LightningInvoiceState() +} + +sealed class BitcoinAddressState { + object Init : BitcoinAddressState() + data class Show(val currentIndex: Int, val currentAddress: String, val image: ImageBitmap) : BitcoinAddressState() + data class Error(val e: Throwable) : BitcoinAddressState() +} class ReceiveViewModel( - val peerManager: PeerManager + private val chain: Chain, + private val peerManager: PeerManager, + private val walletManager: WalletManager, + private val internalDataRepository: InternalDataRepository, + private val context: Context, ): ViewModel() { - val log = LoggerFactory.getLogger(this::class.java) - - sealed class LightningInvoiceState { - object Init : LightningInvoiceState() - object Generating : LightningInvoiceState() - data class Show(val paymentRequest: PaymentRequest) : LightningInvoiceState() - data class Error(val e: Throwable) : LightningInvoiceState() - } - - sealed class BitcoinAddressState { - object Init : BitcoinAddressState() - data class Show(val address: String, val image: ImageBitmap) : BitcoinAddressState() - data class Error(val e: Throwable) : BitcoinAddressState() - } + private val log = LoggerFactory.getLogger(this::class.java) var lightningInvoiceState by mutableStateOf(LightningInvoiceState.Init) - var bitcoinAddressState by mutableStateOf(BitcoinAddressState.Init) + var currentSwapAddress by mutableStateOf(BitcoinAddressState.Init) /** Bitmap containing the LN invoice qr code. It is not stored in the state to avoid brutal transitions and flickering. */ var lightningQRBitmap by mutableStateOf(null) @@ -65,7 +79,7 @@ class ReceiveViewModel( var isEditingLightningInvoice by mutableStateOf(false) init { - generateBitcoinAddress() + monitorCurrentSwapAddress() } @UiThread @@ -95,23 +109,43 @@ class ReceiveViewModel( } @UiThread - private fun generateBitcoinAddress() { - viewModelScope.launch(CoroutineExceptionHandler { _, e -> - log.error("failed to generate address :", e) - bitcoinAddressState = BitcoinAddressState.Error(e) - }) { - val address = peerManager.getPeer().swapInAddress - val image = BitmapHelper.generateBitmap(address).asImageBitmap() - bitcoinAddressState = BitcoinAddressState.Show(address, image) + private fun monitorCurrentSwapAddress() { + viewModelScope.launch { + val swapAddressFormat = UserPrefs.getSwapAddressFormat(context).first() + if (swapAddressFormat == SwapAddressFormat.LEGACY) { + val legacySwapInAddress = peerManager.getPeer().swapInWallet.legacySwapInAddress + val image = BitmapHelper.generateBitmap(legacySwapInAddress).asImageBitmap() + currentSwapAddress = BitcoinAddressState.Show(0, legacySwapInAddress, image) + } else { + // immediately set an address using the index saved in settings, so that the user does not have to wait for the wallet to synchronise + val keyManager = walletManager.keyManager.filterNotNull().first() + val startIndex = internalDataRepository.getLastUsedSwapIndex.first() + 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().swapInWallet.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) + } + } } } class Factory( - private val peerManager: PeerManager + private val chain: Chain, + private val peerManager: PeerManager, + private val walletManager: WalletManager, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class, extras: CreationExtras): T { + val application = checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as? PhoenixApplication) @Suppress("UNCHECKED_CAST") - return ReceiveViewModel(peerManager) as T + return ReceiveViewModel(chain, peerManager, walletManager, application.internalDataRepository, application.applicationContext) as T } } } 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 d13bb812b..ffebcc65f 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 @@ -22,9 +22,8 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.Satoshi -import fr.acinq.bitcoin.byteVector -import fr.acinq.lightning.NodeParams import fr.acinq.lightning.blockchain.fee.FeeratePerByte import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.ChannelCommand @@ -55,7 +54,7 @@ sealed class SpliceOutState { } } -class SpliceOutViewModel(private val peerManager: PeerManager, private val chain: NodeParams.Chain): ViewModel() { +class SpliceOutViewModel(private val peerManager: PeerManager, private val chain: Chain): ViewModel() { val log = LoggerFactory.getLogger(this::class.java) var state by mutableStateOf(SpliceOutState.Init) @@ -72,7 +71,7 @@ class SpliceOutViewModel(private val peerManager: PeerManager, private val chain state = SpliceOutState.Preparing(userAmount = amount, feeratePerByte = feeratePerByte) log.debug("preparing splice-out for amount=$amount feerate=${feeratePerByte}sat/vb address=$address") val userFeerate = FeeratePerKw(FeeratePerByte(feeratePerByte)) - val scriptPubKey = Parser.addressToPublicKeyScript(chain, address)!!.byteVector() + val scriptPubKey = Parser.addressToPublicKeyScriptOrNull(chain, address)!! val res = peerManager.getPeer().estimateFeeForSpliceOut( amount = amount, targetFeerate = userFeerate, @@ -107,7 +106,7 @@ class SpliceOutViewModel(private val peerManager: PeerManager, private val chain }) { val response = peerManager.getPeer().spliceOut( amount = amount, - scriptPubKey = Parser.addressToPublicKeyScript(chain, address)!!.byteVector(), + scriptPubKey = Parser.addressToPublicKeyScriptOrNull(chain, address)!!, feerate = feerate, ) when (response) { @@ -129,7 +128,7 @@ class SpliceOutViewModel(private val peerManager: PeerManager, private val chain } class Factory( - private val peerManager: PeerManager, private val chain: NodeParams.Chain + private val peerManager: PeerManager, private val chain: Chain ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { @Suppress("UNCHECKED_CAST") diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/ChannelsWatcher.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/ChannelsWatcher.kt index ff4e3f227..14f9be44c 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/ChannelsWatcher.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/ChannelsWatcher.kt @@ -21,6 +21,7 @@ import androidx.work.CoroutineWorker import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest +import androidx.work.Operation import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager import androidx.work.WorkerParameters @@ -52,7 +53,7 @@ import java.util.concurrent.TimeUnit class ChannelsWatcher(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { override suspend fun doWork(): Result { - log.info("starting channels watcher job") + log.info("starting channels-watcher job") var notificationsManager: NotificationsManager? = null val application = applicationContext as PhoenixApplication @@ -64,8 +65,8 @@ class ChannelsWatcher(context: Context, workerParams: WorkerParameters) : Corout return Result.success() } + val business = PhoenixBusiness(PlatformContext(applicationContext)) try { - val business = PhoenixBusiness(PlatformContext(applicationContext)) notificationsManager = business.notificationsManager when (val encryptedSeed = SeedManager.loadSeedFromDisk(applicationContext)) { is EncryptedSeed.V2.NoAuth -> { @@ -141,7 +142,9 @@ class ChannelsWatcher(context: Context, workerParams: WorkerParameters) : Corout internalData.saveChannelsWatcherOutcome(Outcome.Unknown(currentTimestampMillis())) return Result.failure() } finally { - + business.appConnectionsDaemon?.incrementDisconnectCount(AppConnectionsDaemon.ControlTarget.All) + business.stop(includingDatabase = false) + log.info("stopped channels-watcher business") } } @@ -161,6 +164,10 @@ class ChannelsWatcher(context: Context, workerParams: WorkerParameters) : Corout val work = OneTimeWorkRequest.Builder(ChannelsWatcher::class.java).addTag(WATCHER_WORKER_TAG).build() WorkManager.getInstance(context).enqueueUniqueWork(WATCHER_WORKER_TAG, ExistingWorkPolicy.REPLACE, work) } + + fun cancel(context: Context): Operation { + return WorkManager.getInstance(context).cancelAllWorkByTag(WATCHER_WORKER_TAG) + } } @Serializable 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 bf7b55c88..468db4c8a 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 @@ -11,6 +11,7 @@ import androidx.compose.runtime.mutableStateListOf import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.work.await import com.google.android.gms.tasks.OnCompleteListener import com.google.firebase.messaging.FirebaseMessaging import fr.acinq.bitcoin.TxId @@ -203,9 +204,10 @@ class NodeService : Service() { stopForeground(STOP_FOREGROUND_REMOVE) } }) { + ChannelsWatcher.cancel(applicationContext).await() log.info("starting node from service state=${_state.value?.name} with checkLegacyChannels=$requestCheckLegacyChannels") doStartBusiness(decryptedMnemonics, requestCheckLegacyChannels) - ChannelsWatcher.scheduleASAP(applicationContext) + ChannelsWatcher.schedule(applicationContext) _state.postValue(NodeServiceState.Running) } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/MutualCloseView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/MutualCloseView.kt index 332bffcc9..9e8cf1ba7 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/MutualCloseView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/MutualCloseView.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.journeyapps.barcodescanner.DecoratedBarcodeView +import fr.acinq.bitcoin.BitcoinError import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.utils.msat import fr.acinq.phoenix.android.* @@ -45,7 +46,7 @@ import fr.acinq.phoenix.android.utils.annotatedStringResource import fr.acinq.phoenix.android.utils.monoTypo import fr.acinq.phoenix.android.utils.mutedBgColor import fr.acinq.phoenix.controllers.config.CloseChannelsConfiguration -import fr.acinq.phoenix.data.BitcoinAddressError +import fr.acinq.phoenix.data.BitcoinUriError import fr.acinq.phoenix.utils.Parser @@ -159,8 +160,11 @@ fun MutualCloseView( is Either.Left -> { val error = validation.value addressErrorMessage = when (error) { - is BitcoinAddressError.ChainMismatch -> context.getString(R.string.mutualclose_error_chain_mismatch) - is BitcoinAddressError.UnhandledRequiredParams -> context.getString(R.string.mutualclose_error_chain_reqparams) + is BitcoinUriError.InvalidScript -> when (error.error) { + is BitcoinError.ChainHashMismatch -> context.getString(R.string.mutualclose_error_chain_mismatch) + else -> context.getString(R.string.mutualclose_error_chain_generic) + } + is BitcoinUriError.UnhandledRequiredParams -> context.getString(R.string.mutualclose_error_chain_reqparams) else -> context.getString(R.string.mutualclose_error_chain_generic) } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/PaymentSettingsView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/PaymentSettingsView.kt index 8ecd48a96..81abed974 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/PaymentSettingsView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/PaymentSettingsView.kt @@ -41,6 +41,7 @@ import fr.acinq.phoenix.android.LocalBitcoinUnit import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.components.* import fr.acinq.phoenix.android.navController +import fr.acinq.phoenix.android.utils.datastore.SwapAddressFormat import fr.acinq.phoenix.android.utils.datastore.UserPrefs import fr.acinq.phoenix.data.lnurl.LnurlAuth import kotlinx.coroutines.launch @@ -60,8 +61,7 @@ fun PaymentSettingsView( val invoiceDefaultDesc by UserPrefs.getInvoiceDefaultDesc(LocalContext.current).collectAsState(initial = "") val invoiceDefaultExpiry by UserPrefs.getInvoiceDefaultExpiry(LocalContext.current).collectAsState(null) - - val prefLnurlAuthSchemeState = UserPrefs.getLnurlAuthScheme(context).collectAsState(initial = null) + val swapAddressFormatState = UserPrefs.getSwapAddressFormat(context).collectAsState(initial = null) DefaultScreenLayout { DefaultScreenHeader( @@ -87,8 +87,42 @@ fun PaymentSettingsView( }, onClick = { showExpiryDialog = true } ) + + val swapAddressFormat = swapAddressFormatState.value + if (swapAddressFormat != null) { + val schemes = listOf( + PreferenceItem( + item = SwapAddressFormat.TAPROOT_ROTATE, + title = stringResource(id = R.string.paymentsettings_swap_format_taproot_title), + description = stringResource(id = R.string.paymentsettings_swap_format_taproot_desc) + ), + PreferenceItem( + item = SwapAddressFormat.LEGACY, + title = stringResource(id = R.string.paymentsettings_swap_format_legacy_title), + description = stringResource(id = R.string.paymentsettings_swap_format_legacy_desc) + ), + ) + ListPreferenceButton( + title = stringResource(id = R.string.paymentsettings_swap_format_title), + subtitle = { + when (swapAddressFormat) { + SwapAddressFormat.LEGACY -> Text(text = stringResource(id = R.string.paymentsettings_swap_format_legacy_title)) + SwapAddressFormat.TAPROOT_ROTATE -> Text(text = stringResource(id = R.string.paymentsettings_swap_format_taproot_title)) + else -> Text(text = stringResource(id = R.string.utils_unknown)) + } + }, + enabled = true, + selectedItem = swapAddressFormat, + preferences = schemes, + onPreferenceSubmit = { + scope.launch { UserPrefs.saveSwapAddressFormat(context, it.item) } + }, + initialShowDialog = false + ) + } } + val prefLnurlAuthSchemeState = UserPrefs.getLnurlAuthScheme(context).collectAsState(initial = null) val prefLnurlAuthScheme = prefLnurlAuthSchemeState.value if (prefLnurlAuthScheme != null) { CardHeader(text = stringResource(id = R.string.paymentsettings_category_lnurl)) 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 67c6c755b..19574ecd6 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 @@ -43,8 +43,8 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel +import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.byteVector -import fr.acinq.lightning.NodeParams import fr.acinq.phoenix.android.LocalBitcoinUnit import fr.acinq.phoenix.android.LocalFiatCurrency import fr.acinq.phoenix.android.MainActivity @@ -95,7 +95,7 @@ class ResetWalletViewModel : ViewModel() { fun deleteWalletData( context: Context, - chain: NodeParams.Chain, + chain: Chain, nodeIdHash: String, onShutdownBusiness: () -> Unit, onShutdownService: () -> Unit, diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/SettingsView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/SettingsView.kt index 10bf6c54c..73ae60c59 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/SettingsView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/SettingsView.kt @@ -45,7 +45,7 @@ import fr.acinq.phoenix.android.components.Card import fr.acinq.phoenix.android.components.CardHeader import fr.acinq.phoenix.android.components.DefaultScreenHeader import fr.acinq.phoenix.android.components.DefaultScreenLayout -import fr.acinq.phoenix.android.components.SettingButton +import fr.acinq.phoenix.android.components.MenuButton import fr.acinq.phoenix.android.navController import fr.acinq.phoenix.android.navigate import fr.acinq.phoenix.android.utils.negativeColor @@ -82,12 +82,12 @@ fun SettingsView( // -- general CardHeader(text = stringResource(id = R.string.settings_general_title)) Card { - SettingButton(text = stringResource(R.string.settings_about), icon = R.drawable.ic_help_circle, onClick = { nc.navigate(Screen.About) }) - SettingButton(text = stringResource(R.string.settings_display_prefs), icon = R.drawable.ic_brush, onClick = { nc.navigate(Screen.Preferences) }) - SettingButton(text = stringResource(R.string.settings_payment_settings), icon = R.drawable.ic_tool, onClick = { nc.navigate(Screen.PaymentSettings) }) - SettingButton(text = stringResource(R.string.settings_liquidity_policy), icon = R.drawable.ic_settings, onClick = { nc.navigate(Screen.LiquidityPolicy) }) - SettingButton(text = stringResource(R.string.settings_payment_history), icon = R.drawable.ic_list, onClick = { nc.navigate(Screen.PaymentsHistory) }) - SettingButton( + MenuButton(text = stringResource(R.string.settings_about), icon = R.drawable.ic_help_circle, onClick = { nc.navigate(Screen.About) }) + MenuButton(text = stringResource(R.string.settings_display_prefs), icon = R.drawable.ic_brush, onClick = { nc.navigate(Screen.Preferences) }) + MenuButton(text = stringResource(R.string.settings_payment_settings), icon = R.drawable.ic_tool, onClick = { nc.navigate(Screen.PaymentSettings) }) + MenuButton(text = stringResource(R.string.settings_liquidity_policy), icon = R.drawable.ic_settings, onClick = { nc.navigate(Screen.LiquidityPolicy) }) + MenuButton(text = stringResource(R.string.settings_payment_history), icon = R.drawable.ic_list, onClick = { nc.navigate(Screen.PaymentsHistory) }) + MenuButton( text = stringResource(R.string.settings_notifications) + ((notices.size + notifications.value.size).takeIf { it > 0 }?.let { " ($it)"} ?: ""), icon = R.drawable.ic_notification, onClick = { nc.navigate(Screen.Notifications) } @@ -97,25 +97,25 @@ fun SettingsView( // -- privacy & security CardHeader(text = stringResource(id = R.string.settings_security_title)) Card { - SettingButton(text = stringResource(R.string.settings_access_control), icon = R.drawable.ic_unlock, onClick = { nc.navigate(Screen.AppLock) }) - SettingButton(text = stringResource(R.string.settings_display_seed), icon = R.drawable.ic_key, onClick = { nc.navigate(Screen.DisplaySeed) }) - SettingButton(text = stringResource(R.string.settings_electrum), icon = R.drawable.ic_chain, onClick = { nc.navigate(Screen.ElectrumServer) }) - SettingButton(text = stringResource(R.string.settings_tor), icon = R.drawable.ic_tor_shield, onClick = { nc.navigate(Screen.TorConfig) }) + MenuButton(text = stringResource(R.string.settings_access_control), icon = R.drawable.ic_unlock, onClick = { nc.navigate(Screen.AppLock) }) + MenuButton(text = stringResource(R.string.settings_display_seed), icon = R.drawable.ic_key, onClick = { nc.navigate(Screen.DisplaySeed) }) + MenuButton(text = stringResource(R.string.settings_electrum), icon = R.drawable.ic_chain, onClick = { nc.navigate(Screen.ElectrumServer) }) + MenuButton(text = stringResource(R.string.settings_tor), icon = R.drawable.ic_tor_shield, onClick = { nc.navigate(Screen.TorConfig) }) } // -- advanced CardHeader(text = stringResource(id = R.string.settings_advanced_title)) Card { - SettingButton(text = stringResource(R.string.settings_wallet_info), icon = R.drawable.ic_box, onClick = { nc.navigate(Screen.WalletInfo) }) - SettingButton(text = stringResource(R.string.settings_list_channels), icon = R.drawable.ic_zap, onClick = { nc.navigate(Screen.Channels) }) - SettingButton(text = stringResource(R.string.settings_logs), icon = R.drawable.ic_text, onClick = { nc.navigate(Screen.Logs) }) + MenuButton(text = stringResource(R.string.settings_wallet_info), icon = R.drawable.ic_box, onClick = { nc.navigate(Screen.WalletInfo) }) + MenuButton(text = stringResource(R.string.settings_list_channels), icon = R.drawable.ic_zap, onClick = { nc.navigate(Screen.Channels) }) + MenuButton(text = stringResource(R.string.settings_logs), icon = R.drawable.ic_text, onClick = { nc.navigate(Screen.Logs) }) } // -- advanced CardHeader(text = stringResource(id = R.string.settings_danger_title)) Card { - SettingButton(text = stringResource(R.string.settings_mutual_close), icon = R.drawable.ic_cross_circle, onClick = { nc.navigate(Screen.MutualClose) }) - SettingButton(text = stringResource(id = R.string.settings_reset_wallet), icon = R.drawable.ic_trash, onClick = { nc.navigate(Screen.ResetWallet) }) - SettingButton( + MenuButton(text = stringResource(R.string.settings_mutual_close), icon = R.drawable.ic_cross_circle, onClick = { nc.navigate(Screen.MutualClose) }) + MenuButton(text = stringResource(id = R.string.settings_reset_wallet), icon = R.drawable.ic_trash, onClick = { nc.navigate(Screen.ResetWallet) }) + MenuButton( text = stringResource(R.string.settings_force_close), textStyle = MaterialTheme.typography.button.copy(color = negativeColor), icon = R.drawable.ic_alert_triangle, diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInAddresses.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInAddresses.kt new file mode 100644 index 000000000..ed4319885 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInAddresses.kt @@ -0,0 +1,132 @@ +/* + * 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.settings.walletinfo + +import androidx.compose.foundation.layout.PaddingValues +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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import fr.acinq.lightning.blockchain.electrum.WalletState +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.business +import fr.acinq.phoenix.android.components.Button +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.DefaultScreenHeader +import fr.acinq.phoenix.android.components.DefaultScreenLayout +import fr.acinq.phoenix.android.components.ItemCard +import fr.acinq.phoenix.android.components.ProgressView +import fr.acinq.phoenix.android.components.openLink +import fr.acinq.phoenix.android.utils.copyToClipboard +import fr.acinq.phoenix.utils.BlockchainExplorer + + +@Composable +fun SwapInAddresses( + onBackClick: () -> Unit, +) { + val vm = viewModel(factory = SwapInAddressesViewModel.Factory(business.peerManager)) + + DefaultScreenLayout(isScrollable = false) { + DefaultScreenHeader( + onBackClick = onBackClick, + title = stringResource(id = R.string.swapinaddresses_title), + ) + + val addresses = vm.taprootAddresses + LazyColumn(modifier = Modifier.fillMaxWidth()) { + item { + CardHeader(text = stringResource(id = R.string.swapinaddresses_taproot)) + Spacer(modifier = Modifier.height(8.dp)) + } + if (addresses.isEmpty()) { + item { + Card(modifier = Modifier.fillMaxWidth()) { + ProgressView(text = stringResource(id = R.string.swapinaddresses_sync), padding = PaddingValues(horizontal = 16.dp, vertical = 12.dp)) + } + } + } else { + itemsIndexed(addresses) { index, (address, state) -> + ItemCard(index = index, maxItemsCount = addresses.size) { + AddressStateView(address = address, state = state) + } + } + } + vm.legacyAddress.value?.let { (address, state) -> + item { + Spacer(modifier = Modifier.height(16.dp)) + CardHeader(text = stringResource(id = R.string.swapinaddresses_legacy)) + } + item { + Card { + AddressStateView(address = address, state = state) + } + } + } + } + } +} + + +@Composable +private fun AddressStateView( + address: String, + state: WalletState.AddressState, +) { + val context = LocalContext.current + val link = business.blockchainExplorer.addressUrl(addr = address, website = BlockchainExplorer.Website.MempoolSpace) + Clickable(onClick = { openLink(context, link) }) { + Row( + modifier = Modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = Modifier.width(16.dp)) + val meta = state.meta + if (meta is WalletState.AddressMeta.Derived) { + Text( + text = meta.index.toString(), + style = MaterialTheme.typography.caption, + textAlign = TextAlign.End, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(text = address, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + Button( + icon = R.drawable.ic_copy, + onClick = { copyToClipboard(context, address, "Copy address") }, + padding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + ) + } + } + +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInAddressesViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInAddressesViewModel.kt new file mode 100644 index 000000000..54ef63dc1 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInAddressesViewModel.kt @@ -0,0 +1,75 @@ +/* + * 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.settings.walletinfo + +import androidx.annotation.UiThread +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.CreationExtras +import fr.acinq.lightning.blockchain.electrum.WalletState +import fr.acinq.phoenix.android.PhoenixApplication +import fr.acinq.phoenix.managers.PeerManager +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory + + +class SwapInAddressesViewModel(private val peerManager: PeerManager) : ViewModel() { + + val log = LoggerFactory.getLogger(this::class.java) + + val taprootAddresses = mutableStateListOf>() + val legacyAddress = mutableStateOf?>(null) + + init { + monitorSwapAddresses() + } + + @UiThread + private fun monitorSwapAddresses() { + viewModelScope.launch { + peerManager.getPeer().swapInWallet.wallet.walletStateFlow.collect { walletState -> + val newAddresses = walletState.addresses.toList().sortedByDescending { + val meta = it.second.meta + if (meta is WalletState.AddressMeta.Derived) { + meta.index + } else { + -1 // legacy address goes to the bottom + } + } + val (legacy, taprootList) = newAddresses.last() to newAddresses.dropLast(1) + log.info("swap-in taproot addresses update: ${taprootAddresses.size} -> ${taprootList.size}") + taprootAddresses.clear() + taprootAddresses.addAll(taprootList) + legacyAddress.value = legacy + } + } + } + + class Factory( + private val peerManager: PeerManager, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class, extras: CreationExtras): T { + val application = checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as? PhoenixApplication) + @Suppress("UNCHECKED_CAST") + return SwapInAddressesViewModel(peerManager) as T + } + } +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInSignerViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInSignerViewModel.kt index 209d0b717..136b1f867 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInSignerViewModel.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInSignerViewModel.kt @@ -79,18 +79,15 @@ class SwapInSignerViewModel( return@launch } val keyManager = walletManager.keyManager.filterNotNull().first() - val userSig = Transactions.signSwapInputUser( + val userSig = keyManager.swapInOnChainWallet.signSwapInputUserLegacy( fundingTx = tx, index = 0, - parentTxOut = TxOut(amount, ByteVector.empty), - userKey = keyManager.swapInOnChainWallet.userPrivateKey, - serverKey = keyManager.swapInOnChainWallet.remoteServerPublicKey, - refundDelay = 144 * 30 * 6 + parentTxOuts = listOf(TxOut(amount, ByteVector.empty)), ) state.value = SwapInSignerState.Signed( amount = amount, txId = tx.txid.toString(), - userSig = userSig.toString() + userSig = userSig.toString(), ) } } 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 48b9b07b7..0fa8f39e3 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023 ACINQ SAS + * 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. @@ -69,8 +69,9 @@ import java.text.DecimalFormat import kotlin.math.ceil import kotlin.math.roundToInt + @Composable -fun SwapInWalletInfo( +fun SwapInWallet( onBackClick: () -> Unit, onViewChannelPolicyClick: () -> Unit, onAdvancedClick: () -> Unit, @@ -90,7 +91,7 @@ fun SwapInWalletInfo( IconPopup( popupMessage = stringResource(id = R.string.walletinfo_onchain_swapin_help), popupLink = stringResource(id = R.string.walletinfo_onchain_swapin_help_faq_link) - to "https://phoenix.acinq.co/faq#can-i-deposit-funds-on-chain-to-phoenix-and-how-long-does-it-take-before-i-can-use-it" + to "https://phoenix.acinq.co/faq#can-i-deposit-funds-on-chain-to-phoenix-and-how-long-does-it-take-before-i-can-use-it" ) Spacer(Modifier.weight(1f)) Box(contentAlignment = Alignment.TopEnd) { @@ -304,4 +305,4 @@ private fun RefundView( style = MaterialTheme.typography.caption.copy(fontSize = 14.sp), ) } -} +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/WalletInfoView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/WalletInfoView.kt index 8ecacc2ae..689fded40 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/WalletInfoView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/WalletInfoView.kt @@ -35,10 +35,13 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import fr.acinq.bitcoin.DeterministicWallet import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.NodeParams import fr.acinq.lightning.blockchain.electrum.WalletState import fr.acinq.lightning.blockchain.electrum.balance +import fr.acinq.lightning.crypto.KeyManager.SwapInOnChainKeys.Companion.swapInUserRefundKeyPath import fr.acinq.lightning.utils.sat import fr.acinq.lightning.utils.toMilliSatoshi import fr.acinq.phoenix.android.LocalBitcoinUnit @@ -48,6 +51,7 @@ import fr.acinq.phoenix.android.components.* import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.monoTypo import fr.acinq.phoenix.android.utils.mutedTextColor +import fr.acinq.phoenix.managers.NodeParamsManager import fr.acinq.phoenix.managers.finalOnChainWalletPath @Composable @@ -55,12 +59,13 @@ fun WalletInfoView( onBackClick: () -> Unit, onLightningWalletClick: () -> Unit, onSwapInWalletClick: () -> Unit, + onSwapInWalletInfoClick: () -> Unit, onFinalWalletClick: () -> Unit, ) { DefaultScreenLayout { DefaultScreenHeader(onBackClick = onBackClick, title = stringResource(id = R.string.walletinfo_title)) OffChainWalletView(onLightningWalletClick) - SwapInWalletView(onSwapInWalletClick) + SwapInWalletView(onSwapInWalletClick, onSwapInWalletInfoClick) FinalWalletView(onFinalWalletClick) } } @@ -100,7 +105,10 @@ private fun OffChainWalletView(onLightningWalletClick: () -> Unit) { } @Composable -private fun SwapInWalletView(onSwapInWalletClick: () -> Unit) { +private fun SwapInWalletView( + onSwapInWalletClick: () -> Unit, + onSwapInWalletInfoClick: () -> Unit, +) { val swapInWallet by business.peerManager.swapInWallet.collectAsState() val keyManager by business.walletManager.keyManager.collectAsState() @@ -108,29 +116,44 @@ private fun SwapInWalletView(onSwapInWalletClick: () -> Unit) { text = stringResource(id = R.string.walletinfo_onchain_swapin), helpMessage = stringResource(id = R.string.walletinfo_onchain_swapin_help), ) - Card( - modifier = Modifier.fillMaxWidth(), - onClick = onSwapInWalletClick, - ) { - swapInWallet?.let { wallet -> - OnchainBalanceView( - confirmed = (wallet.deeplyConfirmed + wallet.lockedUntilRefund + wallet.readyForRefund).balance, - unconfirmed = wallet.unconfirmed.balance + wallet.weaklyConfirmed.balance - ) - } ?: ProgressView(text = stringResource(id = R.string.walletinfo_loading_data)) + Card(modifier = Modifier.fillMaxWidth()) { + MenuButton( + content = { + swapInWallet?.let { + OnchainBalanceView( + confirmed = (it.deeplyConfirmed + it.lockedUntilRefund + it.readyForRefund).balance, + unconfirmed = (it.unconfirmed + it.weaklyConfirmed).balance + ) + } ?: ProgressView(text = stringResource(id = R.string.walletinfo_loading_data)) + }, + onClick = onSwapInWalletClick, + modifier = Modifier.fillMaxWidth(), + ) + + HSeparator(modifier = Modifier.padding(start = 16.dp), width = 50.dp) + keyManager?.let { - HSeparator(modifier = Modifier.padding(start = 16.dp), width = 50.dp) + SettingWithCopy( + title = stringResource(id = R.string.walletinfo_descriptor_legacy), + value = it.swapInOnChainWallet.legacyDescriptor, + maxLinesValue = 1 + ) SettingWithCopy( title = stringResource(id = R.string.walletinfo_descriptor), - value = it.swapInOnChainWallet.descriptor, - maxLinesValue = 2 + value = it.swapInOnChainWallet.publicDescriptor, + maxLinesValue = 1 ) SettingWithCopy( title = stringResource(id = R.string.walletinfo_swapin_user_pubkey), value = it.swapInOnChainWallet.userPublicKey.toHex(), - maxLinesValue = 2 + maxLinesValue = 1 ) } + MenuButton( + text = stringResource(id = R.string.walletinfo_swapin_addresses), + icon = R.drawable.ic_list, + onClick = onSwapInWalletInfoClick + ) } } @@ -143,13 +166,15 @@ private fun FinalWalletView(onFinalWalletClick: () -> Unit) { text = stringResource(id = R.string.walletinfo_onchain_final), helpMessage = stringResource(id = R.string.walletinfo_onchain_final_about) ) - Card( - modifier = Modifier.fillMaxWidth(), - onClick = onFinalWalletClick, - ) { - finalWallet?.let { wallet -> - OnchainBalanceView(confirmed = wallet.deeplyConfirmed.balance, unconfirmed = wallet.unconfirmed.balance) - } ?: ProgressView(text = stringResource(id = R.string.walletinfo_loading_data)) + Card(modifier = Modifier.fillMaxWidth()) { + MenuButton( + content = { + finalWallet?.let { wallet -> + OnchainBalanceView(confirmed = wallet.deeplyConfirmed.balance, unconfirmed = wallet.unconfirmed.balance) + } ?: ProgressView(text = stringResource(id = R.string.walletinfo_loading_data)) + }, + onClick =onFinalWalletClick, + ) keyManager?.let { HSeparator(modifier = Modifier.padding(start = 16.dp), width = 50.dp) SettingWithCopy( @@ -186,9 +211,7 @@ private fun OnchainBalanceView( unconfirmed: Satoshi?, ) { val btcUnit = LocalBitcoinUnit.current - Row(modifier = Modifier - .fillMaxWidth() - .padding(16.dp)) { + Row(modifier = Modifier.fillMaxWidth()) { when (confirmed) { null -> Text(text = stringResource(id = R.string.walletinfo_loading_data), color = mutedTextColor) else -> { @@ -205,6 +228,8 @@ private fun OnchainBalanceView( text = stringResource(id = R.string.walletinfo_incoming_balance, it.toPrettyString(btcUnit, withUnit = true)), style = MaterialTheme.typography.caption.copy(fontSize = 14.sp), modifier = Modifier.alignByBaseline(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } 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 4a0a8e2d1..ac0be1da0 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 @@ -19,10 +19,12 @@ package fr.acinq.phoenix.android.utils import android.content.Context import com.google.common.net.HostAndPort import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.PublicKey import fr.acinq.bitcoin.Satoshi import fr.acinq.bitcoin.TxId import fr.acinq.bitcoin.byteVector32 +import fr.acinq.bitcoin.utils.Either import fr.acinq.eclair.db.IncomingPaymentStatus import fr.acinq.eclair.db.OutgoingPaymentStatus import fr.acinq.eclair.db.PaymentType @@ -35,6 +37,7 @@ import fr.acinq.lightning.* import fr.acinq.lightning.db.* import fr.acinq.lightning.io.Peer import fr.acinq.lightning.io.TcpSocket +import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.payment.FinalFailure import fr.acinq.lightning.payment.PaymentRequest import fr.acinq.lightning.utils.* @@ -293,7 +296,7 @@ object LegacyMigrationHelper { } } else { IncomingPayment.Origin.Invoice( - paymentRequest = PaymentRequest.read(fr.acinq.eclair.payment.PaymentRequest.write(payment.paymentRequest())).get() + paymentRequest = Bolt11Invoice.read(fr.acinq.eclair.payment.PaymentRequest.write(payment.paymentRequest())).get() ) } @@ -329,7 +332,7 @@ object LegacyMigrationHelper { JavaConversions.asJavaCollection(payments).toList().groupBy { UUID.fromString(it.parentId().toString()) } fun modernizeLegacyOutgoingPayment( - chain: NodeParams.Chain, + chain: Chain, parentId: UUID, listOfParts: List, paymentMeta: PaymentMeta?, @@ -363,20 +366,20 @@ object LegacyMigrationHelper { } val paymentRequest = if (head.paymentRequest().isDefined) { - PaymentRequest.read(fr.acinq.eclair.payment.PaymentRequest.write(head.paymentRequest().get())).get() + Bolt11Invoice.read(fr.acinq.eclair.payment.PaymentRequest.write(head.paymentRequest().get())).get() } else null // retrieve details from the first payment in the list val details = if (paymentMeta?.swap_out_address != null) { LightningOutgoingPayment.Details.SwapOut( address = paymentMeta.swap_out_address ?: "", - paymentRequest = paymentRequest ?: PaymentRequest.create( + paymentRequest = paymentRequest ?: Bolt11Invoice.create( chainHash = chain.chainHash, amount = head.recipientAmount().toLong().msat, paymentHash = head.paymentHash().bytes().toArray().byteVector32(), privateKey = Lightning.randomKey(), description = Either.Left("swap-out to ${paymentMeta.swap_out_address} for ${paymentMeta.swap_out_feerate_per_byte} sat/b"), - minFinalCltvExpiryDelta = PaymentRequest.DEFAULT_MIN_FINAL_EXPIRY_DELTA, + minFinalCltvExpiryDelta = Bolt11Invoice.DEFAULT_MIN_FINAL_EXPIRY_DELTA, features = Features.empty ), swapOutFee = paymentMeta.swap_out_fee_sat?.sat ?: 0.sat @@ -493,7 +496,7 @@ fun WalletPaymentInfo.isLegacyMigration(peer: Peer?): Boolean? { return when { p !is ChannelCloseOutgoingPayment -> false peer == null -> null - p.address == peer.swapInAddress && metadata.userDescription == LegacyMigrationHelper.migrationDescFlag -> true + p.address == peer.swapInWallet.legacySwapInAddress && metadata.userDescription == LegacyMigrationHelper.migrationDescFlag -> true else -> false } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/Theme.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/Theme.kt index 8c6fb6002..6d12d9a73 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/Theme.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/Theme.kt @@ -257,7 +257,7 @@ val mutedTextColor @Composable get() = if (isDarkTheme) gray600 else gray200 val mutedBgColor @Composable get() = if (isDarkTheme) gray950 else gray30 -val borderColor @Composable get() = if (isDarkTheme) gray800 else gray70 +val borderColor @Composable get() = if (isDarkTheme) gray800 else gray50 private val systemStatusBarColor @Composable get() = topGradientColor diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/InternalDataRepository.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/InternalDataRepository.kt index 4c24507c3..7fba2736c 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/InternalDataRepository.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/InternalDataRepository.kt @@ -52,10 +52,12 @@ class InternalDataRepository(private val internalData: DataStore) { private val SHOW_INTRO = booleanPreferencesKey("SHOW_INTRO") private val FCM_TOKEN = stringPreferencesKey("FCM_TOKEN") private val CHANNELS_WATCHER_OUTCOME = stringPreferencesKey("CHANNELS_WATCHER_RESULT") + private val LAST_USED_SWAP_INDEX = intPreferencesKey("LAST_USED_SWAP_INDEX") } val log = LoggerFactory.getLogger(this::class.java) + /** Retrieve data stored in [internalData], with a fallback to empty data if prefs file can't be read. */ private val safeData: Flow = internalData.data.catch { exception -> if (exception is IOException) { emit(emptyPreferences()) @@ -127,4 +129,7 @@ class InternalDataRepository(private val internalData: DataStore) { it[LAST_REJECTED_ONCHAIN_SWAP_TIMESTAMP] = currentTimestampMillis() } + val getLastUsedSwapIndex: Flow = safeData.map { it[LAST_USED_SWAP_INDEX] ?: 0 } + suspend fun saveLastUsedSwapIndex(index: Int) = internalData.edit { it[LAST_USED_SWAP_INDEX] = index } + } \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefs.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefs.kt index a18d4e28f..d318fb714 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefs.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/UserPrefs.kt @@ -170,6 +170,15 @@ object UserPrefs { } } + private val SWAP_ADDRESS_FORMAT = intPreferencesKey("SWAP_ADDRESS_FORMAT") + fun getSwapAddressFormat(context: Context): Flow = prefs(context).map { + it[SWAP_ADDRESS_FORMAT]?.let { SwapAddressFormat.getFormatForCode(it) } ?: SwapAddressFormat.TAPROOT_ROTATE + } + suspend fun saveSwapAddressFormat(context: Context, format: SwapAddressFormat) = context.userPrefs.edit { + log.info("saving swap-address-format=$format") + it[SWAP_ADDRESS_FORMAT] = format.code + } + // -- liquidity policy private val LIQUIDITY_POLICY = stringPreferencesKey("LIQUIDITY_POLICY") @@ -268,3 +277,13 @@ enum class HomeAmountDisplayMode { } } } + +enum class SwapAddressFormat(val code: Int) { + LEGACY(0), TAPROOT_ROTATE(1); + companion object { + fun getFormatForCode(code: Int) = when (code) { + 0 -> LEGACY + else -> TAPROOT_ROTATE + } + } +} 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 85b3c38c5..2edd6a44b 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 @@ -29,6 +29,7 @@ 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.security.cert.CertificateException import java.util.* import kotlin.contracts.ExperimentalContracts @@ -121,7 +122,7 @@ fun Connection.CLOSED.isBadCertificate() = this.reason?.cause is CertificateExce */ fun WalletPayment.smartDescription(context: Context): String? = when (this) { is LightningOutgoingPayment -> when (val details = this.details) { - is LightningOutgoingPayment.Details.Normal -> details.paymentRequest.description + is LightningOutgoingPayment.Details.Normal -> details.paymentRequest.desc is LightningOutgoingPayment.Details.KeySend -> context.getString(R.string.paymentdetails_desc_keysend) is LightningOutgoingPayment.Details.SwapOut -> context.getString(R.string.paymentdetails_desc_swapout, details.address) } diff --git a/phoenix-android/src/main/res/drawable/ic_chevron_right.xml b/phoenix-android/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 000000000..104edb87c --- /dev/null +++ b/phoenix-android/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,13 @@ + + + diff --git a/phoenix-android/src/main/res/drawable/ic_history.xml b/phoenix-android/src/main/res/drawable/ic_history.xml new file mode 100644 index 000000000..0f21986a8 --- /dev/null +++ b/phoenix-android/src/main/res/drawable/ic_history.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/phoenix-android/src/main/res/values/important_strings.xml b/phoenix-android/src/main/res/values/important_strings.xml index 84a390edd..96ed5b187 100644 --- a/phoenix-android/src/main/res/values/important_strings.xml +++ b/phoenix-android/src/main/res/values/important_strings.xml @@ -261,7 +261,7 @@ Swap-in wallet - Phoenix is a Lightning wallet. Funds received on-chain will then be moved into Lightning automatically, according to your channel management settings. + The swap-in wallet manages on-chain funds deposited to Phoenix.\n\nIt swaps them automatically to Lightning when possible, according to your channels management setting. See how it works Tap to configure There are no swaps in progress. @@ -282,7 +282,7 @@ Cancelled funds These funds were not swapped in time. Use the wallet\'s descriptor to access them. - An on-chain wallet derived from your seed.\n\nThe final wallet is where funds are sent by default when your Lightning channels are closed. + The final wallet is where funds are sent when your Lightning channels are closed or when there is a problem. It usually should be empty. diff --git a/phoenix-android/src/main/res/values/strings.xml b/phoenix-android/src/main/res/values/strings.xml index c081719e0..7a4f03356 100644 --- a/phoenix-android/src/main/res/values/strings.xml +++ b/phoenix-android/src/main/res/values/strings.xml @@ -111,6 +111,7 @@ Bitcoin This QR code is a classic Bitcoin address.\n\nIt can be read by almost any Bitcoin service/wallet, but payments will be slower to arrive. Address + Synchronizing address… Could not generate a bitcoin address Bitcoin address Share this Bitcoin address with… @@ -201,6 +202,7 @@ Open link in a browser Open transaction in an explorer + Open address in an explorer This field cannot be blank Please enter a valid amount Please enter a valid number @@ -628,6 +630,12 @@ LNURL authentication scheme + Bitcoin address format + Legacy + A less efficient and less private format that does not rotate addresses. However, it is compatible with almost every services and wallets. + Taproot (recommended) + Default format, with better privacy, cheaper fees and address rotation. Some services or wallets may however not understand the address. + Argentine Peso (official rate) @@ -669,10 +677,13 @@ Wallet info + Legacy descriptor Descriptor User public key + Swap addresses Master public key (Path: %1$s) + Ready for swap Waiting for %1$d confirmations +%1$d more… @@ -681,6 +692,11 @@ +%1$s incoming Loading wallet data… + Swap-in addresses + Synchronizing… + Taproot + Legacy + Lightning Node id Show legacy node id 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 f732c220f..077f48bc7 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 @@ -20,10 +20,10 @@ import com.squareup.sqldelight.EnumColumnAdapter import com.squareup.sqldelight.db.SqlDriver import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.PublicKey import fr.acinq.bitcoin.byteVector32 import fr.acinq.eclair.db.sqlite.SqlitePaymentsDb -import fr.acinq.lightning.NodeParams import fr.acinq.lightning.ShortChannelId import fr.acinq.lightning.db.* import fr.acinq.lightning.payment.FinalFailure @@ -90,7 +90,7 @@ class LegacyMigrationHelperTest { // transform legacy payments to modern OutgoingPayment objects val newOutgoingPayments = legacyOutgoingPayments.map { LegacyMigrationHelper.modernizeLegacyOutgoingPayment( - chainHash = NodeParams.Chain.Testnet.chainHash, + chain = Chain.Testnet, 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 84a30253d..921f0f007 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 @@ -1,12 +1,11 @@ package fr.acinq.phoenix.utils import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.Crypto -import fr.acinq.bitcoin.PublicKey import fr.acinq.bitcoin.byteVector64 import fr.acinq.bitcoin.scala.Block import fr.acinq.bitcoin.scala.`MnemonicCode$` -import fr.acinq.lightning.NodeParams import fr.acinq.lightning.crypto.LocalKeyManager import fr.acinq.phoenix.data.lnurl.LnurlAuth import fr.acinq.phoenix.legacy.lnurl.LNUrlAuthFragment @@ -27,7 +26,7 @@ class LnurlAuthTest { val legacyKeyManager = fr.acinq.eclair.crypto.LocalKeyManager(seed, Block.TestnetGenesisBlock().hash()) val kmpKeyManager = LocalKeyManager( seed = seed.toArray().byteVector64(), - chain = NodeParams.Chain.Testnet, + chain = Chain.Testnet, remoteSwapInExtendedPublicKey = "tpubDDt5vQap1awkyDXx1z1cP7QFKSZHDCCpbU8nSq9jy7X2grTjUVZDePexf6gc6AHtRRzkgfPW87K6EKUVV6t3Hu2hg7YkHkmMeLSfrP85x41" ) @@ -66,7 +65,7 @@ class LnurlAuthTest { val legacyKeyManager = fr.acinq.eclair.crypto.LocalKeyManager(seed, Block.LivenetGenesisBlock().hash()) val kmpKeyManager = LocalKeyManager( seed = seed.toArray().byteVector64(), - chain = NodeParams.Chain.Testnet, + chain = Chain.Testnet, remoteSwapInExtendedPublicKey = "tpubDDt5vQap1awkyDXx1z1cP7QFKSZHDCCpbU8nSq9jy7X2grTjUVZDePexf6gc6AHtRRzkgfPW87K6EKUVV6t3Hu2hg7YkHkmMeLSfrP85x41" ) diff --git a/phoenix-android/src/test/kotlin/fr/acinq/phoenix/utils/LnurlPayTest.kt b/phoenix-android/src/test/kotlin/fr/acinq/phoenix/utils/LnurlPayTest.kt index e8888a2b0..8ea4beaf5 100644 --- a/phoenix-android/src/test/kotlin/fr/acinq/phoenix/utils/LnurlPayTest.kt +++ b/phoenix-android/src/test/kotlin/fr/acinq/phoenix/utils/LnurlPayTest.kt @@ -17,7 +17,6 @@ package fr.acinq.phoenix.utils import fr.acinq.bitcoin.ByteVector -import fr.acinq.lightning.Lightning import fr.acinq.lightning.utils.toByteVector import fr.acinq.phoenix.data.lnurl.LnurlPay import org.junit.Assert diff --git a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj index 2a07f19c5..e208f9546 100644 --- a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj +++ b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj @@ -90,6 +90,8 @@ DC355E212A44D838008E8A8E /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC355E202A44D838008E8A8E /* NotificationsView.swift */; }; DC355E232A45FAF2008E8A8E /* NestedObservableObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC355E222A45FAF2008E8A8E /* NestedObservableObject.swift */; }; DC355E252A45FDD3008E8A8E /* NoticeMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC355E242A45FDD3008E8A8E /* NoticeMonitor.swift */; }; + DC370A892B7FBD7C0093C56F /* BtcAddrOptionsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC370A882B7FBD7C0093C56F /* BtcAddrOptionsSheet.swift */; }; + DC370A8B2B7FFFC70093C56F /* SwapInAddresses.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC370A8A2B7FFFC70093C56F /* SwapInAddresses.swift */; }; DC384D81265C12B700131772 /* Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC384D80265C12B700131772 /* Cache.swift */; }; DC384D83265C32F100131772 /* TextField+Verbatim.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC384D82265C32F100131772 /* TextField+Verbatim.swift */; }; DC39A2662A12C04D00F59E39 /* LiquidityPolicyHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC39A2652A12C04D00F59E39 /* LiquidityPolicyHelp.swift */; }; @@ -460,6 +462,8 @@ DC355E202A44D838008E8A8E /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = ""; }; DC355E222A45FAF2008E8A8E /* NestedObservableObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NestedObservableObject.swift; sourceTree = ""; }; DC355E242A45FDD3008E8A8E /* NoticeMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeMonitor.swift; sourceTree = ""; }; + DC370A882B7FBD7C0093C56F /* BtcAddrOptionsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BtcAddrOptionsSheet.swift; sourceTree = ""; }; + DC370A8A2B7FFFC70093C56F /* SwapInAddresses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapInAddresses.swift; sourceTree = ""; }; DC384D7C265BE41900131772 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = fr; path = fr.lproj/about.html; sourceTree = ""; }; DC384D80265C12B700131772 /* Cache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Cache.swift; sourceTree = ""; }; DC384D82265C32F100131772 /* TextField+Verbatim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TextField+Verbatim.swift"; sourceTree = ""; }; @@ -924,6 +928,7 @@ DC2F431527B6983B0006FCC4 /* CopyOptionsSheet.swift */, DC2F431727B698E20006FCC4 /* ShareOptionsSheet.swift */, DC2F431927B699800006FCC4 /* ModifyInvoiceSheet.swift */, + DC370A882B7FBD7C0093C56F /* BtcAddrOptionsSheet.swift */, ); path = receive; sourceTree = ""; @@ -1359,6 +1364,7 @@ DCFAEFC82A72F48700330088 /* SwapInWalletDetails.swift */, DC43096D2A7953F400E28995 /* FinalWalletDetails.swift */, DC43096F2A795F9900E28995 /* UtxoWrapper.swift */, + DC370A8A2B7FFFC70093C56F /* SwapInAddresses.swift */, ); path = wallet; sourceTree = ""; @@ -1688,6 +1694,7 @@ DC49FE9B2AC49CB500D8D2E2 /* KotlinExtensions+Lightning.swift in Sources */, DCACF6FA2566D0BA0009B01E /* KeyStoreError.swift in Sources */, DC0E31BB26EFDED4002071C6 /* VSlider.swift in Sources */, + DC370A892B7FBD7C0093C56F /* BtcAddrOptionsSheet.swift in Sources */, DC27E4CB2791D17A00C777CC /* RecoveryPhraseView.swift in Sources */, DCB410892902D5BF00CE4FF9 /* PaymentsSection.swift in Sources */, DCA6DED0282AB7E20073C658 /* KeychainConstants.swift in Sources */, @@ -1703,6 +1710,7 @@ 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 */, DCD1208728663F4A00EB39C5 /* TransactionsView.swift in Sources */, DC118BFC27B4504B0080BBAC /* ScanView.swift in Sources */, diff --git a/phoenix-ios/phoenix-ios/Localizable.xcstrings b/phoenix-ios/phoenix-ios/Localizable.xcstrings index c43befe4a..5f49deb0a 100644 --- a/phoenix-ios/phoenix-ios/Localizable.xcstrings +++ b/phoenix-ios/phoenix-ios/Localizable.xcstrings @@ -2632,6 +2632,9 @@ } } } + }, + "A less efficient and less private format that does not rotate addresses. However, it is compatible with almost every service and wallet." : { + }, "A new attempt is scheduled in a few hours." : { "localizations" : { @@ -4879,6 +4882,9 @@ } } } + }, + "Bitcoin address format" : { + }, "Bitcoin mempool is full and fees are high." : { "localizations" : { @@ -8125,6 +8131,9 @@ } } } + }, + "Default format, with better privacy, cheaper fees and address rotation. Some older services or wallets may not understand this modern address format." : { + }, "Default payment description" : { "localizations" : { @@ -13937,6 +13946,12 @@ } } } + }, + "Legacy" : { + + }, + "Legacy descriptor" : { + }, "Legal" : { "localizations" : { @@ -19023,6 +19038,9 @@ } } } + }, + "Public key" : { + }, "QR code" : { "localizations" : { @@ -21486,6 +21504,9 @@ } } } + }, + "Share Text (bitcoin address)" : { + }, "Share Text (lightning invoice)" : { "localizations" : { @@ -22678,6 +22699,9 @@ }, "Support" : { "comment" : "HomeView: Tools menu: Label" + }, + "Swap addresses" : { + }, "Swap Fees" : { "comment" : "Label in SummaryInfoGrid", @@ -22801,6 +22825,9 @@ } } } + }, + "Swap-in addresses" : { + }, "Swap-in wallet" : { "comment" : "Navigation Bar Title", @@ -22916,6 +22943,12 @@ } } } + }, + "Taproot" : { + + }, + "Taproot (recommended)" : { + }, "Terminate App" : { "localizations" : { @@ -23036,6 +23069,7 @@ }, "The address is not for %@" : { "comment" : "Error message - parsing bitcoin address", + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift index 2e100347a..2e18c24b4 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift @@ -23,6 +23,26 @@ extension Lightning_kmpConnection { } } +extension Lightning_kmpPaymentRequest { + + var createdAtDate: Date? { + + if let bolt11 = self as? Lightning_kmpBolt11Invoice { + return bolt11.timestampDate + } else { // todo: Bolt12 + return nil + } + } + + var invoiceDescription_: String? { + if let bolt11 = self as? Lightning_kmpBolt11Invoice { + return bolt11.description_ + } else { // todo: Bolt12 + return nil + } + } +} + extension Lightning_kmpWalletState.WalletWithConfirmations { var unconfirmedBalance: Bitcoin_kmpSatoshi { diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift index d843ba060..64ff457d1 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift @@ -43,7 +43,7 @@ extension BalanceManager { extension WalletManager { - func getKeyManager() -> Lightning_kmpLocalKeyManager? { + func keyManagerValue() -> Lightning_kmpLocalKeyManager? { if let value = keyManager.value_ as? Lightning_kmpLocalKeyManager { return value } else { @@ -152,3 +152,10 @@ extension LnurlAuth { } } +extension PlatformContext { + + static var `default`: PlatformContext { + return PlatformContext(logger: KotlinLogger.shared.logger) + } +} + diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift index 8ed7b3f94..80e5645b3 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift @@ -61,7 +61,7 @@ extension WalletPaymentInfo { if let lightningPayment = payment as? Lightning_kmpLightningOutgoingPayment { if let normal = lightningPayment.details.asNormal() { - return sanitize(normal.paymentRequest.desc()) + return sanitize(normal.paymentRequest.desc) } else if let swapOut = lightningPayment.details.asSwapOut() { return sanitize(swapOut.address) } @@ -250,7 +250,7 @@ extension Lightning_kmpOnChainOutgoingPayment { } } -extension Lightning_kmpPaymentRequest { +extension Lightning_kmpBolt11Invoice { var timestampDate: Date { return timestampSeconds.toDate(from: .seconds) diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Lightning.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Lightning.swift index f7ce1f887..c9290595d 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Lightning.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Lightning.swift @@ -98,3 +98,38 @@ extension Lightning_kmpNodeParams { } } } + +extension Lightning_kmpSwapInWallet { + + fileprivate struct _Key { + static var swapInAddressPublisher = 0 + } + + struct SwapInAddressInfo { + let addr: String + let index: Int + } + + func swapInAddressPublisher() -> AnyPublisher { + + self.getSetAssociatedObject(storageKey: &_Key.swapInAddressPublisher) { + + /// Transforming from Kotlin: + /// `MutableStateFlow?>` + KotlinCurrentValueSubject>( + self.swapInAddressFlow + ) + .map { + if let pair = $0, + let addr = pair.first as? String, + let index = pair.second + { + return SwapInAddressInfo(addr: addr, index: index.intValue) + } else { + return nil + } + } + .eraseToAnyPublisher() + } + } +} diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinTypes.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinTypes.swift index a2e8ed90d..06979bbc7 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinTypes.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinTypes.swift @@ -7,16 +7,15 @@ extension Receive { typealias Model_Awaiting = ModelAwaiting typealias Model_Generating = ModelGenerating typealias Model_Generated = ModelGenerated - typealias Model_SwapIn = ModelSwapIn } extension Scan { typealias Model_Ready = ModelReady typealias Model_BadRequest = ModelBadRequest - typealias Model_InvoiceFlow = ModelInvoiceFlow - typealias Model_InvoiceFlow_InvoiceRequest = ModelInvoiceFlowInvoiceRequest - typealias Model_InvoiceFlow_Sending = ModelInvoiceFlowSending + typealias Model_Bolt11InvoiceFlow = ModelBolt11InvoiceFlow + typealias Model_Bolt11InvoiceFlow_InvoiceRequest = ModelBolt11InvoiceFlowBolt11InvoiceRequest + typealias Model_Bolt11InvoiceFlow_Sending = ModelBolt11InvoiceFlowSending typealias Model_OnChainFlow = ModelOnchainFlow @@ -39,7 +38,7 @@ extension Scan { typealias Intent_Parse = IntentParse - typealias Intent_InvoiceFlow_SendInvoicePayment = IntentInvoiceFlowSendInvoicePayment + typealias Intent_Bolt11InvoiceFlow_SendInvoicePayment = IntentBolt11InvoiceFlowSendBolt11Invoice typealias Intent_CancelLnurlServiceFetch = IntentCancelLnurlServiceFetch @@ -68,7 +67,7 @@ extension Scan { typealias BadRequestReason_UnknownFormat = BadRequestReasonUnknownFormat typealias BadRequestReason_UnsupportedLnurl = BadRequestReasonUnsupportedLnurl - typealias ClipboardContent_InvoiceRequest = ClipboardContentInvoiceRequest + typealias ClipboardContent_Bolt11InvoiceRequest = ClipboardContentBolt11InvoiceRequest typealias ClipboardContent_BitcoinRequest = ClipboardContentBitcoinRequest typealias ClipboardContent_LoginRequest = ClipboardContentLoginRequest typealias ClipboardContent_LnurlRequest = ClipboardContentLnurlRequest diff --git a/phoenix-ios/phoenix-ios/officers/BusinessManager.swift b/phoenix-ios/phoenix-ios/officers/BusinessManager.swift index f1b1d0dac..c314e001e 100644 --- a/phoenix-ios/phoenix-ios/officers/BusinessManager.swift +++ b/phoenix-ios/phoenix-ios/officers/BusinessManager.swift @@ -85,7 +85,7 @@ class BusinessManager { private init() { // must use shared instance - business = PhoenixBusiness(ctx: PlatformContext(logger: KotlinLogger.shared.logger)) + business = PhoenixBusiness(ctx: PlatformContext.default) BusinessManager._isTestnet = business.chain.isTestnet() } @@ -119,13 +119,13 @@ class BusinessManager { public func stop() { cancellables.removeAll() - business.stop() + business.stop(includingDatabase: true) syncManager?.shutdown() } public func reset() { - business = PhoenixBusiness(ctx: PlatformContext()) + business = PhoenixBusiness(ctx: PlatformContext.default) syncManager = nil swapInRejectedPublisher.send(nil) walletInfo = nil @@ -322,6 +322,19 @@ class BusinessManager { } }.store(in: &cancellables) + + // Keep Prefs.shared.swapInAddressIndex up-to-date + Biz.business.peerManager.peerStatePublisher() + .flatMap { $0.swapInWallet.swapInAddressPublisher() } + .sink { (newInfo: Lightning_kmpSwapInWallet.SwapInAddressInfo?) in + + if let newInfo { + if Prefs.shared.swapInAddressIndex < newInfo.index { + Prefs.shared.swapInAddressIndex = newInfo.index + } + } + } + .store(in: &cancellables) } func startTasks() { diff --git a/phoenix-ios/phoenix-ios/prefs/Prefs.swift b/phoenix-ios/phoenix-ios/prefs/Prefs.swift index 84945dec6..c33a5bd56 100644 --- a/phoenix-ios/phoenix-ios/prefs/Prefs.swift +++ b/phoenix-ios/phoenix-ios/prefs/Prefs.swift @@ -20,6 +20,7 @@ fileprivate enum Key: String { case showOriginalFiatAmount case recentPaymentsConfig case hasMergedChannelsForSplicing + case swapInAddressIndex } fileprivate enum KeyDeprecated: String { @@ -150,6 +151,11 @@ class Prefs { set { defaults.isNewWallet = newValue } } + var swapInAddressIndex: Int { + get { defaults.swapInAddressIndex } + set { defaults.swapInAddressIndex = newValue } + } + // -------------------------------------------------- // MARK: Recent Tips // -------------------------------------------------- @@ -207,6 +213,7 @@ class Prefs { defaults.removeObject(forKey: Key.showOriginalFiatAmount.rawValue) defaults.removeObject(forKey: Key.recentPaymentsConfig.rawValue) defaults.removeObject(forKey: Key.hasMergedChannelsForSplicing.rawValue) + defaults.removeObject(forKey: Key.swapInAddressIndex.rawValue) self.backupTransactions.resetWallet(encryptedNodeId: encryptedNodeId) self.backupSeed.resetWallet(encryptedNodeId: encryptedNodeId) @@ -301,4 +308,9 @@ extension UserDefaults { get { bool(forKey: Key.hasMergedChannelsForSplicing.rawValue) } set { set(newValue, forKey: Key.hasMergedChannelsForSplicing.rawValue) } } + + @objc fileprivate var swapInAddressIndex: Int { + get { integer(forKey: Key.swapInAddressIndex.rawValue) } + set { set(newValue, forKey: Key.swapInAddressIndex.rawValue) } + } } diff --git a/phoenix-ios/phoenix-ios/sync/SyncManager.swift b/phoenix-ios/phoenix-ios/sync/SyncManager.swift index bca1a12ba..af471029a 100644 --- a/phoenix-ios/phoenix-ios/sync/SyncManager.swift +++ b/phoenix-ios/phoenix-ios/sync/SyncManager.swift @@ -25,7 +25,7 @@ class SyncManager { private var cancellables = Set() init( - chain: Lightning_kmpNodeParams.Chain, + chain: Bitcoin_kmpChain, recoveryPhrase: RecoveryPhrase, cloudKey: Bitcoin_kmpByteVector32, encryptedNodeId: String diff --git a/phoenix-ios/phoenix-ios/sync/SyncSeedManager.swift b/phoenix-ios/phoenix-ios/sync/SyncSeedManager.swift index 9d5a87c50..3d213f2a1 100644 --- a/phoenix-ios/phoenix-ios/sync/SyncSeedManager.swift +++ b/phoenix-ios/phoenix-ios/sync/SyncSeedManager.swift @@ -42,7 +42,7 @@ class SyncSeedManager: SyncManagerProtcol { /// The chain in use by PhoenixBusiness (e.g. Testnet) /// - private let chain: Lightning_kmpNodeParams.Chain + private let chain: Bitcoin_kmpChain /// The 12-word recovery phrase (and associated language) for the wallet. /// @@ -73,7 +73,7 @@ class SyncSeedManager: SyncManagerProtcol { private var cancellables = Set() - init(chain: Lightning_kmpNodeParams.Chain, recoveryPhrase: RecoveryPhrase, encryptedNodeId: String) { + init(chain: Bitcoin_kmpChain, recoveryPhrase: RecoveryPhrase, encryptedNodeId: String) { log.trace("init()") self.chain = chain @@ -95,7 +95,7 @@ class SyncSeedManager: SyncManagerProtcol { // ---------------------------------------- public class func fetchSeeds( - chain: Lightning_kmpNodeParams.Chain + chain: Bitcoin_kmpChain ) -> PassthroughSubject { let publisher = PassthroughSubject() @@ -524,7 +524,7 @@ class SyncSeedManager: SyncManagerProtcol { // MARK: Utilities // ---------------------------------------- - private class func record_table_name(chain: Lightning_kmpNodeParams.Chain) -> String { + 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 diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/SwapInAddresses.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/SwapInAddresses.swift new file mode 100644 index 000000000..7b88e9b62 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/SwapInAddresses.swift @@ -0,0 +1,162 @@ +import SwiftUI +import PhoenixShared + +fileprivate let filename = "SwapInAddresses" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct SwapInAddresses: View { + + let swapInAddressPublisher = Biz.business.peerManager.peerStatePublisher().flatMap { $0.swapInWallet.swapInAddressPublisher() + } + @State var swapInAddressInfo: Lightning_kmpSwapInWallet.SwapInAddressInfo? = nil + + @StateObject var toast = Toast() + + @Environment(\.colorScheme) var colorScheme: ColorScheme + + // -------------------------------------------------- + // MARK: View Builders + // -------------------------------------------------- + + @ViewBuilder + var body: some View { + + ZStack { + content() + toast.view() + } + .navigationTitle("Swap-in addresses") + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + func content() -> some View { + + List { + section_taproot() + section_legacy() + } + .listStyle(.insetGrouped) + .listBackgroundColor(.primaryBackground) + .onReceive(swapInAddressPublisher) { + swapInAddressChanged($0) + } + } + + @ViewBuilder + func section_taproot() -> some View { + + Section { + + let count = taprootAddressCount() + ForEach((0.. some View { + + Section { + + let address = legacyAddress() + HStack(alignment: VerticalAlignment.center, spacing: 4) { + Text(address) + .lineLimit(1) + .truncationMode(.middle) + + Button { + copyAddressToPasteboard(address) + } label: { + Image(systemName: "square.on.square") + } + } + + } header: { + Text("Legacy") + } + } + + // -------------------------------------------------- + // MARK: View Helpers + // -------------------------------------------------- + + func taprootAddressCount() -> Int { + + let lastIndex = swapInAddressInfo?.index ?? Prefs.shared.swapInAddressIndex + return lastIndex + 1 + } + + func taprootAddress(_ index: Int) -> String { + + guard let keyManager = Biz.business.walletManager.keyManagerValue() else { + return "???" + } + + return keyManager.swapInOnChainWallet + .getSwapInProtocol(addressIndex: Int32(index)) + .address(chain: Biz.business.chain) + } + + func legacyAddress() -> String { + + guard let keyManager = Biz.business.walletManager.keyManagerValue() else { + return "???" + } + + return keyManager.swapInOnChainWallet + .legacySwapInProtocol + .address(chain: Biz.business.chain) + } + + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + func swapInAddressChanged(_ newInfo: Lightning_kmpSwapInWallet.SwapInAddressInfo?) { + log.trace("swapInAddressChanged()") + + self.swapInAddressInfo = newInfo + } + + // -------------------------------------------------- + // MARK: Actions + // -------------------------------------------------- + + func copyAddressToPasteboard(_ address: String) -> Void { + log.trace("copyAddressToPasteboard()") + + UIPasteboard.general.string = address + toast.pop( + NSLocalizedString("Copied to pasteboard!", comment: "Toast message"), + colorScheme: colorScheme.opposite, + style: .chrome + ) + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/WalletInfoView.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/WalletInfoView.swift index 2862d4e15..93f4d6d75 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/WalletInfoView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/WalletInfoView.swift @@ -11,6 +11,7 @@ fileprivate var log = LoggerFactory.shared.logger(filename, .warning) fileprivate enum NavLinkTag: String { case SwapInWalletDetails + case SwapInAddresses case FinalWalletDetails } @@ -132,7 +133,12 @@ struct WalletInfoView: View { navLink(.SwapInWalletDetails) { subsection_swapInWallet_balance() } + subsection_swapInWallet_legacyDescriptor() subsection_swapInWallet_descriptor() + subsection_swapInWallet_publicKey() + navLink(.SwapInAddresses) { + subsection_swapInWallet_swapAddresses() + } } header: { subsection_swapInWallet_header() @@ -175,11 +181,38 @@ struct WalletInfoView: View { ) } + @ViewBuilder + func subsection_swapInWallet_legacyDescriptor() -> some View { + + let keyManager = Biz.business.walletManager.keyManagerValue() + let descriptor = keyManager?.swapInOnChainWallet.legacyDescriptor ?? "?" + + VStack(alignment: HorizontalAlignment.leading, spacing: 10) { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Legacy descriptor") + .font(.headline.bold()) + Spacer() + copyButton(descriptor) + } + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text(descriptor) + .lineLimit(1) + .font(.callout.weight(.light)) + .foregroundColor(.secondary) + Spacer(minLength: 0) + invisibleImage() + } + + } // + } + @ViewBuilder func subsection_swapInWallet_descriptor() -> some View { - let keyManager = Biz.business.walletManager.getKeyManager() - let descriptor = keyManager?.swapInOnChainWallet.descriptor ?? "?" + let keyManager = Biz.business.walletManager.keyManagerValue() + let descriptor = keyManager?.swapInOnChainWallet.publicDescriptor ?? "?" VStack(alignment: HorizontalAlignment.leading, spacing: 10) { @@ -192,7 +225,34 @@ struct WalletInfoView: View { HStack(alignment: VerticalAlignment.center, spacing: 0) { Text(descriptor) - .lineLimit(2) + .lineLimit(1) + .font(.callout.weight(.light)) + .foregroundColor(.secondary) + Spacer(minLength: 0) + invisibleImage() + } + + } // + } + + @ViewBuilder + func subsection_swapInWallet_publicKey() -> some View { + + let keyManager = Biz.business.walletManager.keyManagerValue() + let pubKeyHex = keyManager?.swapInOnChainWallet.userPublicKey.toHex() ?? "?" + + VStack(alignment: HorizontalAlignment.leading, spacing: 10) { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Public key") + .font(.headline.bold()) + Spacer() + copyButton(pubKeyHex) + } + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text(pubKeyHex) + .lineLimit(1) .font(.callout.weight(.light)) .foregroundColor(.secondary) Spacer(minLength: 0) @@ -202,6 +262,19 @@ struct WalletInfoView: View { } // } + @ViewBuilder + func subsection_swapInWallet_swapAddresses() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Swap addresses") + .font(.headline.bold()) + Image(systemName: "list.bullet") + .padding(.leading, 8) + .foregroundColor(.secondary) + Spacer() + } + } + // -------------------------------------------------- // MARK: View Builders: FinalWallet // -------------------------------------------------- @@ -261,7 +334,7 @@ struct WalletInfoView: View { @ViewBuilder func subsection_finalWallet_masterPublicKey() -> some View { - let keyManager = Biz.business.walletManager.getKeyManager() + let keyManager = Biz.business.walletManager.keyManagerValue() let keyPath = keyManager?.finalOnChainWalletPath ?? "?" let xpub = keyManager?.finalOnChainWallet.xpub ?? "?" @@ -310,7 +383,7 @@ struct WalletInfoView: View { HStack(alignment: VerticalAlignment.center, spacing: 0) { Text(xpub) - .lineLimit(2) + .lineLimit(1) .font(.callout.weight(.light)) .foregroundColor(.secondary) Spacer(minLength: 0) @@ -375,6 +448,7 @@ struct WalletInfoView: View { switch tag { case .SwapInWalletDetails : SwapInWalletDetails(location: .embedded, popTo: popToWrapper) + case .SwapInAddresses : SwapInAddresses() case .FinalWalletDetails : FinalWalletDetails() } } diff --git a/phoenix-ios/phoenix-ios/views/configuration/danger zone/drain wallet/DrainWalletView.swift b/phoenix-ios/phoenix-ios/views/configuration/danger zone/drain wallet/DrainWalletView.swift index 5410547fa..65bd89894 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/danger zone/drain wallet/DrainWalletView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/danger zone/drain wallet/DrainWalletView.swift @@ -335,16 +335,7 @@ struct DrainWalletView: MVIView { log.debug("result.error = \(error)") - if let error = error as? BitcoinAddressError.ChainMismatch { - detailedErrorMsg = String(format: NSLocalizedString( - """ - The address is not for %@ - """, - comment: "Error message - parsing bitcoin address"), - error.expected.name - ) - } - else if isScannedValue { + if isScannedValue { // If the user scanned a non-bitcoin QRcode, we should notify them of the error detailedErrorMsg = NSLocalizedString( "The scanned QR code is not a bitcoin address", @@ -359,10 +350,29 @@ struct DrainWalletView: MVIView { } else { - log.debug("result.info = \(result.right!)") + let bitcoinUri = result.right! + log.debug("result.info = \(bitcoinUri)") + + // Check to make sure the bitcoin address is for the correct chain. + let parsedChain = bitcoinUri.chain + let expectedChain = Biz.business.chain - parsedBitcoinAddress = result.right!.address - detailedErrorMsg = nil + if parsedChain != expectedChain { + + detailedErrorMsg = String(format: NSLocalizedString( + """ + The address is for %@, but you're on %@ + """, + comment: "Error message - parsing bitcoin address"), + parsedChain.name, expectedChain.name + ) + parsedBitcoinAddress = nil + + } else { // looks good + + parsedBitcoinAddress = bitcoinUri.address + detailedErrorMsg = nil + } } if !isScannedValue && scannedValue != nil { diff --git a/phoenix-ios/phoenix-ios/views/inspect/DetailsView.swift b/phoenix-ios/phoenix-ios/views/inspect/DetailsView.swift index 00d5ca723..3232554ef 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/DetailsView.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/DetailsView.swift @@ -472,15 +472,18 @@ fileprivate struct DetailsInfoGrid: InfoGridView { func paymentRequest_invoiceCreated(_ paymentRequest: Lightning_kmpPaymentRequest) -> some View { let identifier: String = #function - InfoGridRowWrapper( - identifier: identifier, - keyColumnWidth: keyColumnWidth(identifier: identifier) - ) { - keyColumn("invoice created") + if let createdAtDate = paymentRequest.createdAtDate { - } valueColumn: { - - commonValue_date(date: paymentRequest.timestampDate) + InfoGridRowWrapper( + identifier: identifier, + keyColumnWidth: keyColumnWidth(identifier: identifier) + ) { + keyColumn("invoice created") + + } valueColumn: { + + commonValue_date(date: createdAtDate) + } } } @@ -488,19 +491,22 @@ fileprivate struct DetailsInfoGrid: InfoGridView { func paymentRequest_invoiceDescription(_ paymentRequest: Lightning_kmpPaymentRequest) -> some View { let identifier: String = #function - InfoGridRowWrapper( - identifier: identifier, - keyColumnWidth: keyColumnWidth(identifier: identifier) - ) { - keyColumn("invoice description") - - } valueColumn: { + if let rawDescription = paymentRequest.invoiceDescription_ { - let description = (paymentRequest.description_ ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if description.isEmpty { - Text("empty").foregroundColor(.secondary) - } else { - Text(description) + InfoGridRowWrapper( + identifier: identifier, + keyColumnWidth: keyColumnWidth(identifier: identifier) + ) { + keyColumn("invoice description") + + } valueColumn: { + + let description = rawDescription.trimmingCharacters(in: .whitespacesAndNewlines) + if description.isEmpty { + Text("empty").foregroundColor(.secondary) + } else { + Text(description) + } } } } diff --git a/phoenix-ios/phoenix-ios/views/receive/BtcAddrOptionsSheet.swift b/phoenix-ios/phoenix-ios/views/receive/BtcAddrOptionsSheet.swift new file mode 100644 index 000000000..d179b40c2 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/receive/BtcAddrOptionsSheet.swift @@ -0,0 +1,173 @@ +import SwiftUI + +fileprivate let filename = "BtcAddrOptionsSheet" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct BtcAddrOptionsSheet: View { + + @Binding var swapInAddressType: SwapInAddressType + + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + header() + content() + } + .onChange(of: swapInAddressType) { _ in + swapInAddressTypeChanged() + } + } + + @ViewBuilder + func header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Bitcoin address format") + .font(.title3) + .accessibilityAddTraits(.isHeader) + .accessibilitySortPriority(100) + Spacer() + Button { + closeSheet() + } label: { + Image(systemName: "xmark").imageScale(.medium).font(.title2) + } + .accessibilityLabel("Close") + .accessibilityHidden(smartModalState.dismissable) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background( + Color(UIColor.secondarySystemBackground) + .cornerRadius(15, corners: [.topLeft, .topRight]) + ) + .padding(.bottom, 4) + } + + @ViewBuilder + func content() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + + Toggle(isOn: taprootBinding()) { + Text("Taproot (recommended)") + .foregroundColor(.appAccent) + .bold() + } + .toggleStyle(CheckboxToggleStyle( + onImage: onImage(), + offImage: offImage() + )) + .padding(.bottom, 5) + + Label { + Text( + """ + Default format, with better privacy, cheaper fees and address rotation. \ + Some older services or wallets may not understand this modern address format. + """ + ) + .font(.subheadline) + .foregroundColor(.secondary) + } icon: { + invisibleImage() + } + .padding(.bottom, 15) + + Toggle(isOn: legacyBinding()) { + Text("Legacy") + .foregroundColor(.appAccent) + .bold() + } + .toggleStyle(CheckboxToggleStyle( + onImage: onImage(), + offImage: offImage() + )) + + Label { + Text( + """ + A less efficient and less private format that does not rotate addresses. \ + However, it is compatible with almost every service and wallet. + """ + ) + .font(.subheadline) + .foregroundColor(.secondary) + } icon: { + invisibleImage() + } + } + .padding() + } + + @ViewBuilder + func onImage() -> some View { + Image(systemName: "smallcircle.filled.circle") + .imageScale(.large) + .foregroundColor(.appAccent) + } + + @ViewBuilder + func offImage() -> some View { + Image(systemName: "circle") + .imageScale(.large) + .foregroundColor(.appAccent) + } + + @ViewBuilder + func invisibleImage() -> some View { + + Image(systemName: "circle") + .imageScale(.large) + .foregroundColor(.clear) + .accessibilityHidden(true) + } + + // -------------------------------------------------- + // MARK: View Helpers + // -------------------------------------------------- + + private func taprootBinding() -> Binding { + + return Binding( + get: { swapInAddressType == .taproot }, + set: { if $0 { swapInAddressType = .taproot }} + ) + } + + private func legacyBinding() -> Binding { + + return Binding( + get: { swapInAddressType == .legacy }, + set: { if $0 { swapInAddressType = .legacy } } + ) + } + + // -------------------------------------------------- + // MARK: Actions + // -------------------------------------------------- + + func swapInAddressTypeChanged() { + log.trace("swapInAddressTypeChanged()") + + // It's nice to add a slight delay before we close the sheet. + // Because it's assuring to visually see the Toggle update, and then see the sheet close. + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + self.closeSheet() + } + } + + func closeSheet() { + log.trace("closeSheet()") + + smartModalState.close() + } +} diff --git a/phoenix-ios/phoenix-ios/views/receive/SwapInView.swift b/phoenix-ios/phoenix-ios/views/receive/SwapInView.swift index 08868bf03..b66a5476c 100644 --- a/phoenix-ios/phoenix-ios/views/receive/SwapInView.swift +++ b/phoenix-ios/phoenix-ios/views/receive/SwapInView.swift @@ -8,6 +8,18 @@ fileprivate var log = LoggerFactory.shared.logger(filename, .trace) fileprivate var log = LoggerFactory.shared.logger(filename, .warning) #endif +enum SwapInAddressType: CustomStringConvertible{ + case taproot + case legacy + + var description: String { + switch self { + case .taproot : return "taproot" + case .legacy : return "legacy" + } + } +} + struct SwapInView: View { enum ReceiveViewSheet { @@ -18,6 +30,7 @@ struct SwapInView: View { @ObservedObject var toast: Toast @State var swapInAddress: String? = nil + @State var swapInAddressType: SwapInAddressType = .taproot @StateObject var qrCode = QRCode() @@ -26,6 +39,10 @@ struct SwapInView: View { let swapInWalletPublisher = Biz.business.balanceManager.swapInWalletPublisher() @State var swapInWallet = Biz.business.balanceManager.swapInWalletValue() + let swapInAddressPublisher = Biz.business.peerManager.peerStatePublisher().flatMap { $0.swapInWallet.swapInAddressPublisher() + } + @State var swapInAddressInfo: Lightning_kmpSwapInWallet.SwapInAddressInfo? = nil + @Environment(\.colorScheme) var colorScheme: ColorScheme @Environment(\.presentationMode) var presentationMode: Binding @@ -83,6 +100,7 @@ struct SwapInView: View { HStack(alignment: VerticalAlignment.center, spacing: 30) { copyButton() shareButton() + editButton() } .assignMaxPreference(for: maxButtonWidthReader.key, to: $maxButtonWidth) .padding(.bottom) @@ -115,6 +133,12 @@ struct SwapInView: View { .onReceive(swapInWalletPublisher) { swapInWalletChanged($0) } + .onReceive(swapInAddressPublisher) { + swapInAddressChanged($0) + } + .onChange(of: swapInAddressType) { + swapInAddressTypeChanged($0) + } } @ViewBuilder @@ -293,6 +317,26 @@ struct SwapInView: View { .simultaneousGesture(TapGesture().onEnded { didTapShareButton() }) + .accessibilityAction(named: "Share Text (bitcoin address)") { + shareTextToSystem() + } + .accessibilityAction(named: "Share Image (QR code)") { + shareImageToSystem() + } + } + + @ViewBuilder + func editButton() -> some View { + + actionButton( + text: NSLocalizedString("edit", comment: "button label - try to make it short"), + image: Image(systemName: "square.and.pencil"), + width: 19, height: 19, + xOffset: 1, yOffset: -1 + ) { + didTapEditButton() + } + .disabled(swapInAddress == nil) } @ViewBuilder @@ -314,30 +358,49 @@ struct SwapInView: View { } // -------------------------------------------------- - // MARK: Notifications + // MARK: View Helpers // -------------------------------------------------- - func onAppear() { - log.trace("onAppear()") + func updateSwapInAddress() { + log.trace("updateSwapInAddress()") + + guard let keyManager = Biz.business.walletManager.keyManagerValue() else { + return + } - Task { @MainActor in + let chain = Biz.business.chain + let address: String + switch swapInAddressType { + case .taproot: + let index = swapInAddressInfo?.index ?? Prefs.shared.swapInAddressIndex + address = keyManager.swapInOnChainWallet + .getSwapInProtocol(addressIndex: Int32(index)) + .address(chain: chain) - let peerManager = Biz.business.peerManager - do { - let peer = try await peerManager.getPeer() - let address = peer.swapInAddress - - self.swapInAddress = address + case .legacy: + address = keyManager.swapInOnChainWallet + .legacySwapInProtocol + .address(chain: chain) + } + + if swapInAddress != address { + swapInAddress = address - // Issue #196: Use uppercase lettering for invoices and address QRs - self.qrCode.generate(value: address.uppercased()) - - } catch { - log.error("Failed fetching swapInAddress: \(error)") - } + // Issue #196: Use uppercase lettering for invoices and address QRs + self.qrCode.generate(value: address.uppercased()) } } + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + func onAppear() { + log.trace("onAppear()") + + updateSwapInAddress() + } + func swapInWalletChanged(_ newWallet: Lightning_kmpWalletState.WalletWithConfirmations) { log.trace("swapInWalletChanged()") @@ -355,6 +418,19 @@ struct SwapInView: View { } } + func swapInAddressChanged(_ newInfo: Lightning_kmpSwapInWallet.SwapInAddressInfo?) { + log.trace("swapInAddressChanged()") + + self.swapInAddressInfo = newInfo + updateSwapInAddress() + } + + func swapInAddressTypeChanged(_ newType: SwapInAddressType) { + log.trace("swapInAddressTypeChanged(new: \(newType)") + + updateSwapInAddress() + } + // -------------------------------------------------- // MARK: Actions // -------------------------------------------------- @@ -397,6 +473,15 @@ struct SwapInView: View { } } + func didTapEditButton() { + log.trace("didTapEditButton()") + + smartModalState.display(dismissable: true) { + + BtcAddrOptionsSheet(swapInAddressType: $swapInAddressType) + } + } + // -------------------------------------------------- // MARK: Utilities // -------------------------------------------------- diff --git a/phoenix-ios/phoenix-ios/views/send/MinerFeeSheet.swift b/phoenix-ios/phoenix-ios/views/send/MinerFeeSheet.swift index 174585871..7aef22b4f 100644 --- a/phoenix-ios/phoenix-ios/views/send/MinerFeeSheet.swift +++ b/phoenix-ios/phoenix-ios/views/send/MinerFeeSheet.swift @@ -490,7 +490,7 @@ struct MinerFeeSheet: View { guard let satsPerByte_number = try? parsedSatsPerByte.get(), let peer = Biz.business.peerManager.peerStateValue(), - let scriptBytes = Parser.shared.addressToPublicKeyScript(chain: Biz.business.chain, address: btcAddress) + let scriptVector = Parser.shared.addressToPublicKeyScriptOrNull(chain: Biz.business.chain, address: btcAddress) else { return } @@ -503,7 +503,6 @@ struct MinerFeeSheet: View { } let originalSatsPerByte = satsPerByte - let scriptVector = Bitcoin_kmpByteVector(bytes: scriptBytes) let satsPerByte_satoshi = Bitcoin_kmpSatoshi(sat: satsPerByte_number.int64Value) let feePerByte = Lightning_kmpFeeratePerByte(feerate: satsPerByte_satoshi) diff --git a/phoenix-ios/phoenix-ios/views/send/ScanView.swift b/phoenix-ios/phoenix-ios/views/send/ScanView.swift index 9d2643a4c..db2f6c98f 100644 --- a/phoenix-ios/phoenix-ios/views/send/ScanView.swift +++ b/phoenix-ios/phoenix-ios/views/send/ScanView.swift @@ -284,11 +284,11 @@ struct ScanView: View { if clipboardContent != nil { Group { - if let content = clipboardContent as? Scan.ClipboardContent_InvoiceRequest { + if let content = clipboardContent as? Scan.ClipboardContent_Bolt11InvoiceRequest { - let desc = content.paymentRequest.description_?.trimmingCharacters(in: .whitespaces) ?? "" + let desc = content.invoice.description_?.trimmingCharacters(in: .whitespaces) ?? "" - if let msat = content.paymentRequest.amount { + if let msat = content.invoice.amount { let amt = Utils.format(currencyPrefs, msat: msat) if desc.isEmpty { @@ -440,7 +440,7 @@ struct ScanView: View { ignoreScanner = false } - case _ as Scan.Model_InvoiceFlow, + case _ as Scan.Model_Bolt11InvoiceFlow, _ as Scan.Model_OnChainFlow, _ as Scan.Model_LnurlPayFlow, _ as Scan.Model_LnurlAuthFlow: diff --git a/phoenix-ios/phoenix-ios/views/send/SendView.swift b/phoenix-ios/phoenix-ios/views/send/SendView.swift index 1e42308c1..0adea857c 100644 --- a/phoenix-ios/phoenix-ios/views/send/SendView.swift +++ b/phoenix-ios/phoenix-ios/views/send/SendView.swift @@ -91,7 +91,7 @@ struct SendView: MVIView { ScanView(location: location, mvi: mvi, toast: toast) .zIndex(4) - case _ as Scan.Model_InvoiceFlow_InvoiceRequest, + case _ as Scan.Model_Bolt11InvoiceFlow_InvoiceRequest, _ as Scan.Model_OnChainFlow, _ as Scan.Model_LnurlPayFlow_LnurlPayRequest, _ as Scan.Model_LnurlPayFlow_LnurlPayFetch, @@ -101,7 +101,7 @@ struct SendView: MVIView { ValidateView(mvi: mvi) .zIndex(3) - case _ as Scan.Model_InvoiceFlow_Sending, + case _ as Scan.Model_Bolt11InvoiceFlow_Sending, _ as Scan.Model_LnurlPayFlow_Sending: PaymentInFlightView(mvi: mvi) @@ -150,7 +150,7 @@ struct SendView: MVIView { needsAcceptWarning = false } - case _ as Scan.Model_InvoiceFlow_InvoiceRequest, + case _ as Scan.Model_Bolt11InvoiceFlow_InvoiceRequest, _ as Scan.Model_OnChainFlow, _ as Scan.Model_LnurlPayFlow_LnurlPayRequest, _ as Scan.Model_LnurlPayFlow_LnurlPayFetch: @@ -159,7 +159,7 @@ struct SendView: MVIView { showSendPaymentWarning() } - case is Scan.Model_InvoiceFlow_Sending, + case is Scan.Model_Bolt11InvoiceFlow_Sending, is Scan.Model_LnurlPayFlow_Sending: // Pop self from NavigationStack; Back to HomeView diff --git a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift index 703a2e5cf..c557faa95 100644 --- a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift +++ b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift @@ -736,10 +736,10 @@ struct ValidateView: View { return nil } - func paymentRequest() -> Lightning_kmpPaymentRequest? { + func paymentRequest() -> Lightning_kmpBolt11Invoice? { - if let model = mvi.model as? Scan.Model_InvoiceFlow_InvoiceRequest { - return model.paymentRequest + if let model = mvi.model as? Scan.Model_Bolt11InvoiceFlow_InvoiceRequest { + return model.invoice } else { // Note: there's technically a `paymentRequest` within `Scan.Model_SwapOutFlow_Ready`. // But this method is designed to only pull from `Scan.Model_InvoiceFlow_InvoiceRequest`. @@ -982,7 +982,7 @@ struct ValidateView: View { // * SwapOutFlow_Ready -> SwapOutFlow_Init => remove minerFee from calculations // * OnChainFlow -> InvoiceFlow_X => range changed (e.g. minAmount) // - if newModel is Scan.Model_InvoiceFlow { + if newModel is Scan.Model_Bolt11InvoiceFlow { refreshAltAmount() } @@ -1461,11 +1461,11 @@ struct ValidateView: View { } } - if let model = mvi.model as? Scan.Model_InvoiceFlow_InvoiceRequest { + if let model = mvi.model as? Scan.Model_Bolt11InvoiceFlow_InvoiceRequest { saveTipPercentInPrefs() - mvi.intent(Scan.Intent_InvoiceFlow_SendInvoicePayment( - paymentRequest: model.paymentRequest, + mvi.intent(Scan.Intent_Bolt11InvoiceFlow_SendInvoicePayment( + invoice: model.invoice, amount: Lightning_kmpMilliSatoshi(msat: msat), trampolineFees: trampolineFees )) diff --git a/phoenix-ios/phoenix-ios/views/transactions/TxHistoryExporter.swift b/phoenix-ios/phoenix-ios/views/transactions/TxHistoryExporter.swift index 5e97b9d7b..19f18c670 100644 --- a/phoenix-ios/phoenix-ios/views/transactions/TxHistoryExporter.swift +++ b/phoenix-ios/phoenix-ios/views/transactions/TxHistoryExporter.swift @@ -439,8 +439,7 @@ struct TxHistoryExporter: View { includesFiat: includeFiat, includesDescription: includeDescription, includesNotes: includeNotes, - includesOriginDestination: includeOriginDestination, - swapInAddress: peer.swapInAddress + includesOriginDestination: includeOriginDestination ) var done = false diff --git a/phoenix-ios/phoenix-notifySrvExt/PhoenixManager.swift b/phoenix-ios/phoenix-notifySrvExt/PhoenixManager.swift index 89a6646e7..507dfafe8 100644 --- a/phoenix-ios/phoenix-notifySrvExt/PhoenixManager.swift +++ b/phoenix-ios/phoenix-notifySrvExt/PhoenixManager.swift @@ -108,7 +108,7 @@ class PhoenixManager { return } - let newBusiness = PhoenixBusiness(ctx: PlatformContext()) + let newBusiness = PhoenixBusiness(ctx: PlatformContext.default) newBusiness.networkMonitor.disable() newBusiness.currencyManager.disableAutoRefresh() @@ -180,7 +180,7 @@ class PhoenixManager { } if let prvBusiness = oldBusiness { - prvBusiness.stop() + prvBusiness.stop(includingDatabase: true) oldBusiness = nil oldCancellables.removeAll() } @@ -317,7 +317,7 @@ class PhoenixManager { connections.electrum is Lightning_kmpConnection.CLOSED { if let prvBusiness = oldBusiness { - prvBusiness.stop() + prvBusiness.stop(includingDatabase: true) oldBusiness = nil oldCancellables.removeAll() } diff --git a/phoenix-shared/build.gradle.kts b/phoenix-shared/build.gradle.kts index ef92a0ff0..8ced4b73c 100644 --- a/phoenix-shared/build.gradle.kts +++ b/phoenix-shared/build.gradle.kts @@ -48,7 +48,7 @@ val buildVersionsTask by tasks.registering(Sync::class) { kotlin { if (includeAndroid) { - android { + androidTarget { compilations.all { kotlinOptions.jvmTarget = "1.8" } @@ -136,27 +136,17 @@ kotlin { } // -- ios sources - val iosX64Main by getting - val iosArm64Main by getting val iosMain by creating { - dependsOn(commonMain) dependencies { implementation("io.ktor:ktor-client-ios:${Versions.ktor}") implementation("com.squareup.sqldelight:native-driver:${Versions.sqlDelight}") } - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) } - val iosX64Test by getting - val iosArm64Test by getting val iosTest by creating { - dependsOn(commonTest) dependencies { implementation("com.squareup.sqldelight:native-driver:${Versions.sqlDelight}") } - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) } all { 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 e2cae98a9..779bfa834 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 @@ -18,15 +18,15 @@ package fr.acinq.phoenix.db import com.squareup.sqldelight.android.AndroidSqliteDriver import com.squareup.sqldelight.db.SqlDriver -import fr.acinq.lightning.NodeParams +import fr.acinq.bitcoin.Chain import fr.acinq.phoenix.utils.PlatformContext import java.util.* -actual fun createChannelsDbDriver(ctx: PlatformContext, chain: NodeParams.Chain, nodeIdHash: String): SqlDriver { +actual fun createChannelsDbDriver(ctx: PlatformContext, chain: Chain, nodeIdHash: String): SqlDriver { return AndroidSqliteDriver(ChannelsDatabase.Schema, ctx.applicationContext, "channels-${chain.name.lowercase()}-$nodeIdHash.sqlite") } -actual fun createPaymentsDbDriver(ctx: PlatformContext, chain: NodeParams.Chain, nodeIdHash: String): SqlDriver { +actual fun createPaymentsDbDriver(ctx: PlatformContext, chain: Chain, nodeIdHash: String): SqlDriver { return AndroidSqliteDriver(PaymentsDatabase.Schema, ctx.applicationContext, "payments-${chain.name.lowercase()}-$nodeIdHash.sqlite") } diff --git a/phoenix-shared/src/androidTest/kotlin/fr/acinq/phoenix/data/ElectrumServersTest.kt b/phoenix-shared/src/androidUnitTest/kotlin/fr/acinq/phoenix/data/ElectrumServersTest.kt similarity index 100% rename from phoenix-shared/src/androidTest/kotlin/fr/acinq/phoenix/data/ElectrumServersTest.kt rename to phoenix-shared/src/androidUnitTest/kotlin/fr/acinq/phoenix/data/ElectrumServersTest.kt diff --git a/phoenix-shared/src/androidTest/kotlin/fr/acinq/phoenix/db/SqliteChannelsDatabaseTest.kt b/phoenix-shared/src/androidUnitTest/kotlin/fr/acinq/phoenix/db/SqliteChannelsDatabaseTest.kt similarity index 100% rename from phoenix-shared/src/androidTest/kotlin/fr/acinq/phoenix/db/SqliteChannelsDatabaseTest.kt rename to phoenix-shared/src/androidUnitTest/kotlin/fr/acinq/phoenix/db/SqliteChannelsDatabaseTest.kt diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/PhoenixBusiness.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/PhoenixBusiness.kt index 4fdae994c..ca5f82a74 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/PhoenixBusiness.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/PhoenixBusiness.kt @@ -16,7 +16,7 @@ package fr.acinq.phoenix -import fr.acinq.lightning.NodeParams +import fr.acinq.bitcoin.Chain import fr.acinq.lightning.blockchain.electrum.ElectrumClient import fr.acinq.lightning.blockchain.electrum.ElectrumWatcher import fr.acinq.lightning.io.TcpSocket @@ -74,7 +74,7 @@ class PhoenixBusiness( } } - val chain: NodeParams.Chain = NodeParamsManager.chain + val chain: Chain = NodeParamsManager.chain val electrumClient by lazy { ElectrumClient(scope = MainScope(), loggerFactory = loggerFactory, pingInterval = 30.seconds, rpcTimeout = 10.seconds) } internal val electrumWatcher by lazy { ElectrumWatcher(electrumClient, MainScope(), loggerFactory) } @@ -111,16 +111,20 @@ class PhoenixBusiness( * It's recommended that you close the network connections (electrum + peer) * BEFORE invoking this function, to ensure a clean disconnect from the server. */ - fun stop() { + fun stop(includingDatabase: Boolean = true) { electrumClient.stop() electrumWatcher.stop() electrumWatcher.cancel() appConnectionsDaemon?.cancel() - appDb.close() + if (includingDatabase) { + appDb.close() + } networkMonitor.stop() walletManager.cancel() nodeParamsManager.cancel() - databaseManager.close() + if (includingDatabase) { + databaseManager.close() + } databaseManager.cancel() databaseManager.cancel() peerManager.cancel() diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/config/CloseChannelsConfigurationController.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/config/CloseChannelsConfigurationController.kt index a8ffc459d..da96972e7 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/config/CloseChannelsConfigurationController.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/config/CloseChannelsConfigurationController.kt @@ -1,8 +1,7 @@ package fr.acinq.phoenix.controllers.config -import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.lightning.NodeParams import fr.acinq.lightning.channel.* import fr.acinq.lightning.channel.states.* import fr.acinq.lightning.io.WrappedChannelCommand @@ -19,7 +18,7 @@ import kotlinx.coroutines.launch class AppCloseChannelsConfigurationController( loggerFactory: LoggerFactory, private val peerManager: PeerManager, - private val chain: NodeParams.Chain, + private val chain: Chain, private val isForceClose: Boolean ) : AppController( loggerFactory = loggerFactory, @@ -94,7 +93,7 @@ class AppCloseChannelsConfigurationController( val closableChannelsList = updatedChannelsList.filter { isClosable(it.status) } - val address = peer.finalAddress + val address = peer.finalWallet.finalAddress model(CloseChannelsConfiguration.Model.Ready( channels = closableChannelsList, address = address @@ -105,15 +104,13 @@ class AppCloseChannelsConfigurationController( } override fun process(intent: CloseChannelsConfiguration.Intent) { - var scriptPubKey : ByteArray? = null - if (intent is CloseChannelsConfiguration.Intent.MutualCloseAllChannels) { - scriptPubKey = Parser.addressToPublicKeyScript(chain, intent.address) - if (scriptPubKey == null) { - throw IllegalArgumentException( - "Address is invalid. Caller MUST validate user input via parseBitcoinAddress" - ) + val scriptPubKey = if (intent is CloseChannelsConfiguration.Intent.MutualCloseAllChannels) { + try { + Parser.readBitcoinAddress(chain, intent.address).right!!.script + } catch (e: Exception) { + throw IllegalArgumentException("Address is invalid. Caller MUST validate user input via readBitcoinAddress") } - } + } else null launch { val peer = peerManager.getPeer() @@ -126,7 +123,7 @@ class AppCloseChannelsConfigurationController( filteredChannels.keys.forEach { channelId -> val command: ChannelCommand = if (scriptPubKey != null) { logger.info { "(mutual) closing channel=${channelId.toHex()}" } - ChannelCommand.Close.MutualClose(scriptPubKey = ByteVector(scriptPubKey), feerates = null) + ChannelCommand.Close.MutualClose(scriptPubKey = scriptPubKey, feerates = null) } else { logger.info { "(force) closing channel=${channelId.toHex()}" } ChannelCommand.Close.ForceClose diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Receive.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Receive.kt index 83b52d092..c4923aab3 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Receive.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Receive.kt @@ -9,7 +9,6 @@ object Receive { object Awaiting : Model() object Generating: Model() data class Generated(val request: String, val paymentHash: String, val amount: MilliSatoshi?, val desc: String?): Model() - data class SwapIn(val address: String): Model() } sealed class Intent : MVI.Intent() { @@ -18,7 +17,6 @@ object Receive { val desc: String?, val expirySeconds: Long = 3600 * 24 * 7 // 7 days ) : Intent() - object RequestSwapIn : Intent() } } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ReceiveController.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ReceiveController.kt index 4cc33d6ac..cdb948419 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ReceiveController.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ReceiveController.kt @@ -16,10 +16,9 @@ package fr.acinq.phoenix.controllers.payments -import co.touchlab.kermit.Logger +import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.lightning.utils.Either import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.controllers.AppController import fr.acinq.phoenix.managers.PeerManager @@ -54,11 +53,6 @@ class AppReceiveController( model(Receive.Model.Generated(paymentRequest.write(), paymentRequest.paymentHash.toHex(), paymentRequest.amount, paymentRequest.description)) } } - Receive.Intent.RequestSwapIn -> { - launch { - model(Receive.Model.SwapIn(peerManager.getPeer().swapInAddress)) - } - } } } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Scan.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Scan.kt index 8d3c8e062..13573335b 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Scan.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Scan.kt @@ -16,10 +16,12 @@ package fr.acinq.phoenix.controllers.payments +import fr.acinq.bitcoin.Bitcoin +import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.NodeParams import fr.acinq.lightning.TrampolineFees +import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.payment.PaymentRequest import fr.acinq.phoenix.controllers.MVI import fr.acinq.phoenix.data.BitcoinUri @@ -37,7 +39,7 @@ object Scan { object UnknownFormat : BadRequestReason() object AlreadyPaidInvoice : BadRequestReason() data class Expired(val timestampSeconds: Long, val expirySeconds: Long) : BadRequestReason() - data class ChainMismatch(val expected: NodeParams.Chain) : BadRequestReason() + data class ChainMismatch(val expected: Chain) : BadRequestReason() data class ServiceError(val url: Url, val error: LnurlError.RemoteFailure) : BadRequestReason() data class InvalidLnurl(val url: Url) : BadRequestReason() data class UnsupportedLnurl(val url: Url) : BadRequestReason() @@ -46,7 +48,7 @@ object Scan { sealed class LnurlPayError { data class RemoteError(val err: LnurlError.RemoteFailure) : LnurlPayError() data class BadResponseError(val err: LnurlError.Pay.Invoice) : LnurlPayError() - data class ChainMismatch(val expected: NodeParams.Chain) : LnurlPayError() + data class ChainMismatch(val expected: Chain) : LnurlPayError() object AlreadyPaidInvoice : LnurlPayError() } @@ -68,12 +70,12 @@ object Scan { val reason: BadRequestReason ) : Model() - sealed class InvoiceFlow : Model() { - data class InvoiceRequest( + sealed class Bolt11InvoiceFlow : Model() { + data class Bolt11InvoiceRequest( val request: String, - val paymentRequest: PaymentRequest, - ): InvoiceFlow() - object Sending: InvoiceFlow() + val invoice: Bolt11Invoice, + ): Bolt11InvoiceFlow() + object Sending: Bolt11InvoiceFlow() } data class OnchainFlow(val uri: BitcoinUri): Model() @@ -139,12 +141,12 @@ object Scan { val request: String ) : Intent() - sealed class InvoiceFlow : Intent() { - data class SendInvoicePayment( - val paymentRequest: PaymentRequest, + sealed class Bolt11InvoiceFlow : Intent() { + data class SendBolt11Invoice( + val invoice: Bolt11Invoice, val amount: MilliSatoshi, val trampolineFees: TrampolineFees - ) : InvoiceFlow() + ) : Bolt11InvoiceFlow() } object CancelLnurlServiceFetch : Intent() @@ -184,8 +186,8 @@ object Scan { } sealed class ClipboardContent { - data class InvoiceRequest( - val paymentRequest: PaymentRequest + data class Bolt11InvoiceRequest( + val invoice: Bolt11Invoice ): ClipboardContent() data class BitcoinRequest( diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ScanController.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ScanController.kt index 8f0ce0103..b1ad04cf5 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ScanController.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ScanController.kt @@ -16,13 +16,13 @@ package fr.acinq.phoenix.controllers.payments -import co.touchlab.kermit.Logger +import fr.acinq.bitcoin.BitcoinError +import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* import fr.acinq.lightning.db.LightningOutgoingPayment import fr.acinq.lightning.io.SendPayment import fr.acinq.lightning.logging.LoggerFactory -import fr.acinq.lightning.payment.PaymentRequest import fr.acinq.lightning.utils.* import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.controllers.AppController @@ -34,6 +34,7 @@ import fr.acinq.phoenix.utils.Parser import fr.acinq.phoenix.utils.extensions.chain import fr.acinq.lightning.logging.error import fr.acinq.lightning.logging.info +import fr.acinq.lightning.payment.Bolt11Invoice import io.ktor.http.Url import kotlinx.coroutines.* import kotlinx.coroutines.flow.filterNotNull @@ -50,7 +51,7 @@ class AppScanController( private val peerManager: PeerManager, private val lnurlManager: LnurlManager, private val databaseManager: DatabaseManager, - private val chain: NodeParams.Chain, + private val chain: Chain, ) : AppController( loggerFactory = loggerFactory, firstModel = firstModel ?: Scan.Model.Ready @@ -81,14 +82,14 @@ class AppScanController( when (intent) { is Scan.Intent.Reset -> launch { model(Scan.Model.Ready) } is Scan.Intent.Parse -> launch { processScannedInput(intent) } - is Scan.Intent.InvoiceFlow.SendInvoicePayment -> launch { - sendPayment( + is Scan.Intent.Bolt11InvoiceFlow.SendBolt11Invoice -> launch { + payBolt11Invoice( amountToSend = intent.amount, trampolineFees = intent.trampolineFees, - paymentRequest = intent.paymentRequest, + invoice = intent.invoice, metadata = null, ) - model(Scan.Model.InvoiceFlow.Sending) + model(Scan.Model.Bolt11InvoiceFlow.Sending) } is Scan.Intent.CancelLnurlServiceFetch -> launch { cancelLnurlFetch() } is Scan.Intent.LnurlPayFlow.RequestInvoice -> launch { processLnurlPayRequestInvoice(intent) } @@ -104,8 +105,8 @@ class AppScanController( ) { val input = Parser.removeExcessInput(intent.request) - Parser.readPaymentRequest(input)?.let { - processLightningInvoice(it) + Parser.readBolt11Invoice(input)?.let { + processBolt11Invoice(it) } ?: readLnurl(input)?.let { processLnurl(it) } ?: readBitcoinAddress(input)?.let { @@ -118,12 +119,12 @@ class AppScanController( } /** Inspects the Lightning invoice for errors and update the model with the adequate value. */ - private suspend fun processLightningInvoice(paymentRequest: PaymentRequest) { - val model = checkForBadRequest(paymentRequest)?.let { - Scan.Model.BadRequest(request = paymentRequest.write(), reason = it) - } ?: Scan.Model.InvoiceFlow.InvoiceRequest( - request = paymentRequest.write(), - paymentRequest = paymentRequest, + private suspend fun processBolt11Invoice(invoice: Bolt11Invoice) { + val model = checkForBadBolt11Invoice(invoice)?.let { + Scan.Model.BadRequest(request = invoice.write(), reason = it) + } ?: Scan.Model.Bolt11InvoiceFlow.Bolt11InvoiceRequest( + request = invoice.write(), + invoice = invoice, ) model(model) } @@ -131,14 +132,14 @@ class AppScanController( /** Return the adequate model for a Bitcoin address result. */ private suspend fun processBitcoinAddress( input: String, - result: Either + result: Either ) { model(when (result) { is Either.Right -> Scan.Model.OnchainFlow(uri = result.value) is Either.Left -> { val error = result.value - if (error is BitcoinAddressError.ChainMismatch) { - Scan.Model.BadRequest(request = input, reason = Scan.BadRequestReason.ChainMismatch(expected = error.expected)) + if (error is BitcoinUriError.InvalidScript && error.error is BitcoinError.ChainHashMismatch) { + Scan.Model.BadRequest(request = input, reason = Scan.BadRequestReason.ChainMismatch(expected = chain)) } else { Scan.Model.BadRequest(request = input, reason = Scan.BadRequestReason.UnknownFormat) } @@ -203,10 +204,10 @@ class AppScanController( } /** Extract invoice and send it to the Peer to make the payment, attaching custom trampoline fees if needed. */ - private suspend fun sendPayment( + private suspend fun payBolt11Invoice( amountToSend: MilliSatoshi, trampolineFees: TrampolineFees, - paymentRequest: PaymentRequest, + invoice: Bolt11Invoice, metadata: WalletPaymentMetadata?, ) { val paymentId = UUID.randomUUID() @@ -224,8 +225,8 @@ class AppScanController( SendPayment( paymentId = paymentId, amount = amountToSend, - recipient = paymentRequest.nodeId, - paymentRequest = paymentRequest, + recipient = invoice.nodeId, + paymentRequest = invoice, trampolineFeesOverride = listOf(trampolineFees) ) ) @@ -252,7 +253,7 @@ class AppScanController( requestPayInvoiceTask = task try { val invoice = task.await() - when (val check = checkForBadRequest(invoice.paymentRequest)) { + when (checkForBadBolt11Invoice(invoice.invoice)) { is Scan.BadRequestReason.ChainMismatch -> Either.Left( Scan.LnurlPayError.ChainMismatch(expected = chain) ) @@ -288,10 +289,10 @@ class AppScanController( ) } is Either.Right -> { - sendPayment( + payBolt11Invoice( amountToSend = intent.amount, trampolineFees = intent.trampolineFees, - paymentRequest = result.value.paymentRequest, + invoice = result.value.invoice, metadata = WalletPaymentMetadata( lnurl = LnurlPayMetadata( pay = intent.paymentIntent, @@ -335,7 +336,7 @@ class AppScanController( val paymentRequest = peerManager.getPeer().createInvoice( paymentPreimage = Lightning.randomBytes32(), amount = intent.amount, - description = fr.acinq.lightning.utils.Either.Left(intent.description ?: intent.lnurlWithdraw.defaultDescription), + description = Either.Left(intent.description ?: intent.lnurlWithdraw.defaultDescription), expirySeconds = (3600 * 24 * 7).toLong(), // one week ) @@ -438,8 +439,8 @@ class AppScanController( fun inspectClipboard(data: String): Scan.ClipboardContent? { val input = Parser.removeExcessInput(data) - return Parser.readPaymentRequest(input)?.let { - Scan.ClipboardContent.InvoiceRequest(it) + return Parser.readBolt11Invoice(input)?.let { + Scan.ClipboardContent.Bolt11InvoiceRequest(it) } ?: readLnurl(input)?.let { when (it) { is LnurlAuth -> Scan.ClipboardContent.LoginRequest(it) @@ -461,21 +462,21 @@ class AppScanController( } /** Checks that the invoice is on same chain and has not already been paid. */ - private suspend fun checkForBadRequest( - paymentRequest: PaymentRequest + private suspend fun checkForBadBolt11Invoice( + invoice: Bolt11Invoice ): Scan.BadRequestReason? { - val actualChain = paymentRequest.chain + val actualChain = invoice.chain if (chain != actualChain) { return Scan.BadRequestReason.ChainMismatch(expected = chain) } - if (paymentRequest.isExpired(currentTimestampSeconds())) { - return Scan.BadRequestReason.Expired(paymentRequest.timestampSeconds, paymentRequest.expirySeconds ?: PaymentRequest.DEFAULT_EXPIRY_SECONDS.toLong()) + if (invoice.isExpired(currentTimestampSeconds())) { + return Scan.BadRequestReason.Expired(invoice.timestampSeconds, invoice.expirySeconds ?: Bolt11Invoice.DEFAULT_EXPIRY_SECONDS.toLong()) } val db = databaseManager.databases.filterNotNull().first() - return if (db.payments.listLightningOutgoingPayments(paymentRequest.paymentHash).any { it.status is LightningOutgoingPayment.Status.Completed.Succeeded }) { + return if (db.payments.listLightningOutgoingPayments(invoice.paymentHash).any { it.status is LightningOutgoingPayment.Status.Completed.Succeeded }) { Scan.BadRequestReason.AlreadyPaidInvoice } else { null @@ -489,14 +490,11 @@ class AppScanController( null } - /** - * Invokes `Parser.readBitcoinAddress`, but maps the - * generic `BitcoinAddressError.UnknownFormat` to a null result instead. - */ - private fun readBitcoinAddress(input: String): Either? { + /** Invokes `Parser.readBitcoinAddress`, but maps [BitcoinUriError.InvalidUri] to a null result instead of a fatal error. */ + private fun readBitcoinAddress(input: String): Either? { return when (val result = Parser.readBitcoinAddress(chain, input)) { is Either.Left -> when (result.left) { - is BitcoinAddressError.UnknownFormat -> null + is BitcoinUriError.InvalidUri -> null else -> result } is Either.Right -> result diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/BitcoinAddress.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/BitcoinAddress.kt index fcbb05a8b..1d96ef7c1 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/BitcoinAddress.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/BitcoinAddress.kt @@ -16,22 +16,25 @@ package fr.acinq.phoenix.data +import fr.acinq.bitcoin.BitcoinError +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.Satoshi -import fr.acinq.lightning.NodeParams -import fr.acinq.lightning.payment.PaymentRequest +import fr.acinq.lightning.payment.Bolt11Invoice import io.ktor.http.* data class BitcoinUri( - val chain: NodeParams.Chain, + val chain: Chain, /** Actual Bitcoin address; may be different than the source, e.g. if the source is an URI like "bitcoin:xyz?param=123". */ val address: String, + val script: ByteVector, // Bip-21 parameters val label: String? = null, val message: String? = null, /** Amount requested in the URI. */ val amount: Satoshi? = null, /** A Bitcoin URI may contain a Lightning payment request as an alternative way to make the payment. */ - val paymentRequest: PaymentRequest? = null, + val paymentRequest: Bolt11Invoice? = null, /** Other bip-21 parameters in the URI that we do not handle. */ val ignoredParams: Parameters = Parameters.Empty, ) { @@ -51,8 +54,8 @@ data class BitcoinUri( } } -sealed class BitcoinAddressError { - data class ChainMismatch(val expected: NodeParams.Chain): BitcoinAddressError() - data class UnhandledRequiredParams(val parameters: List>): BitcoinAddressError() - object UnknownFormat: BitcoinAddressError() +sealed class BitcoinUriError { + data class InvalidScript(val error: BitcoinError): BitcoinUriError() + data class UnhandledRequiredParams(val parameters: List>): BitcoinUriError() + object InvalidUri: BitcoinUriError() } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/lnurl/LnurlPay.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/lnurl/LnurlPay.kt index 74c5b40e7..23c097e47 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/lnurl/LnurlPay.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/lnurl/LnurlPay.kt @@ -18,9 +18,9 @@ package fr.acinq.phoenix.data.lnurl import co.touchlab.kermit.Logger import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.utils.Try import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.payment.PaymentRequest -import fr.acinq.lightning.utils.Try +import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.phoenix.data.lnurl.Lnurl.Companion.format import fr.acinq.phoenix.db.cloud.b64Decode import io.ktor.http.* @@ -68,7 +68,7 @@ sealed class LnurlPay : Lnurl.Qualified { */ data class Invoice( override val initialUrl: Url, - val paymentRequest: PaymentRequest, + val invoice: Bolt11Invoice, val successAction: SuccessAction? ) : LnurlPay() { sealed class SuccessAction { @@ -111,13 +111,13 @@ sealed class LnurlPay : Lnurl.Qualified { ): Invoice { try { val pr = json["pr"]?.jsonPrimitive?.content ?: throw LnurlError.Pay.Invoice.Malformed(origin, "missing pr") - val paymentRequest = when (val res = PaymentRequest.read(pr)) { + val invoice = when (val res = Bolt11Invoice.read(pr)) { is Try.Success -> res.result is Try.Failure -> throw LnurlError.Pay.Invoice.Malformed(origin, res.error.message ?: res.error::class.toString()) } val successAction = parseSuccessAction(origin, json) - return Invoice(intent.initialUrl, paymentRequest, successAction) + return Invoice(intent.initialUrl, invoice, successAction) } catch (t: Throwable) { when (t) { is LnurlError.Pay.Invoice -> throw t diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/DbFactory.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/DbFactory.kt index 522b4b58b..f1cb2acd8 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/DbFactory.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/DbFactory.kt @@ -17,11 +17,11 @@ package fr.acinq.phoenix.db import com.squareup.sqldelight.db.SqlDriver -import fr.acinq.lightning.NodeParams +import fr.acinq.bitcoin.Chain import fr.acinq.phoenix.utils.PlatformContext -expect fun createChannelsDbDriver(ctx: PlatformContext, chain: NodeParams.Chain, nodeIdHash: String): SqlDriver +expect fun createChannelsDbDriver(ctx: PlatformContext, chain: Chain, nodeIdHash: String): SqlDriver -expect fun createPaymentsDbDriver(ctx: PlatformContext, chain: NodeParams.Chain, nodeIdHash: String): SqlDriver +expect fun createPaymentsDbDriver(ctx: PlatformContext, chain: Chain, nodeIdHash: String): SqlDriver expect fun createAppDbDriver(ctx: PlatformContext): SqlDriver \ No newline at end of file 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 001f45f3f..d73e09d8e 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 @@ -23,6 +23,7 @@ import com.squareup.sqldelight.runtime.coroutines.asFlow import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.Crypto import fr.acinq.bitcoin.TxId +import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.channel.ChannelException import fr.acinq.lightning.db.* import fr.acinq.lightning.logging.LoggerFactory diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/IncomingOriginType.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/IncomingOriginType.kt index 3cdfa418e..b4c6321e8 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/IncomingOriginType.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/IncomingOriginType.kt @@ -25,7 +25,7 @@ import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.OutPoint import fr.acinq.bitcoin.TxId import fr.acinq.lightning.db.IncomingPayment -import fr.acinq.lightning.payment.PaymentRequest +import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.phoenix.db.payments.DbTypesHelper.decodeBlob import fr.acinq.phoenix.db.serializers.v1.ByteVector32Serializer import fr.acinq.phoenix.db.serializers.v1.OutpointSerializer @@ -69,7 +69,7 @@ sealed class IncomingOriginData { fun deserialize(typeVersion: IncomingOriginTypeVersion, blob: ByteArray): IncomingPayment.Origin = decodeBlob(blob) { json, format -> when (typeVersion) { IncomingOriginTypeVersion.KEYSEND_V0 -> IncomingPayment.Origin.KeySend - IncomingOriginTypeVersion.INVOICE_V0 -> format.decodeFromString(json).let { IncomingPayment.Origin.Invoice(PaymentRequest.read(it.paymentRequest).get()) } + IncomingOriginTypeVersion.INVOICE_V0 -> format.decodeFromString(json).let { IncomingPayment.Origin.Invoice(Bolt11Invoice.read(it.paymentRequest).get()) } IncomingOriginTypeVersion.SWAPIN_V0 -> format.decodeFromString(json).let { IncomingPayment.Origin.SwapIn(it.address) } IncomingOriginTypeVersion.ONCHAIN_V0 -> format.decodeFromString(json).let { IncomingPayment.Origin.OnChain(TxId(it.txId), it.outpoints.toSet()) diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/OutgoingDetailsType.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/OutgoingDetailsType.kt index cb66333ce..49be4761e 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/OutgoingDetailsType.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/OutgoingDetailsType.kt @@ -24,14 +24,13 @@ package fr.acinq.phoenix.db.payments import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.db.LightningOutgoingPayment -import fr.acinq.lightning.payment.PaymentRequest +import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.phoenix.db.serializers.v1.ByteVector32Serializer import fr.acinq.phoenix.db.serializers.v1.SatoshiSerializer import io.ktor.utils.io.charsets.* import io.ktor.utils.io.core.* import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers -import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -77,9 +76,9 @@ sealed class OutgoingDetailsData { @Suppress("DEPRECATION") fun deserialize(typeVersion: OutgoingDetailsTypeVersion, blob: ByteArray): LightningOutgoingPayment.Details? = DbTypesHelper.decodeBlob(blob) { json, format -> when (typeVersion) { - OutgoingDetailsTypeVersion.NORMAL_V0 -> format.decodeFromString(json).let { LightningOutgoingPayment.Details.Normal(PaymentRequest.read(it.paymentRequest).get()) } + OutgoingDetailsTypeVersion.NORMAL_V0 -> format.decodeFromString(json).let { LightningOutgoingPayment.Details.Normal(Bolt11Invoice.read(it.paymentRequest).get()) } OutgoingDetailsTypeVersion.KEYSEND_V0 -> format.decodeFromString(json).let { LightningOutgoingPayment.Details.KeySend(it.preimage) } - OutgoingDetailsTypeVersion.SWAPOUT_V0 -> format.decodeFromString(json).let { LightningOutgoingPayment.Details.SwapOut(it.address, PaymentRequest.read(it.paymentRequest).get(), it.swapOutFee) } + OutgoingDetailsTypeVersion.SWAPOUT_V0 -> format.decodeFromString(json).let { LightningOutgoingPayment.Details.SwapOut(it.address, Bolt11Invoice.read(it.paymentRequest).get(), it.swapOutFee) } OutgoingDetailsTypeVersion.CLOSING_V0 -> null } } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/OutgoingQueries.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/OutgoingQueries.kt index ee82bd384..4def43ee6 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/OutgoingQueries.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/OutgoingQueries.kt @@ -19,6 +19,7 @@ package fr.acinq.phoenix.db.payments import com.squareup.sqldelight.ColumnAdapter import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.PublicKey +import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.ShortChannelId import fr.acinq.lightning.channel.ChannelException 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 3a76dc35c..df703559e 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 @@ -1,6 +1,6 @@ package fr.acinq.phoenix.managers -import fr.acinq.lightning.NodeParams +import fr.acinq.bitcoin.Chain import fr.acinq.lightning.blockchain.electrum.ElectrumWatcher import fr.acinq.lightning.blockchain.electrum.HeaderSubscriptionResponse import fr.acinq.lightning.blockchain.fee.FeeratePerByte @@ -32,7 +32,7 @@ class AppConfigurationManager( private val appDb: SqliteAppDb, private val httpClient: HttpClient, private val electrumWatcher: ElectrumWatcher, - private val chain: NodeParams.Chain, + private val chain: Chain, loggerFactory: LoggerFactory ) : CoroutineScope by MainScope() { @@ -163,9 +163,10 @@ class AppConfigurationManager( } fun randomElectrumServer() = when (chain) { - NodeParams.Chain.Mainnet -> mainnetElectrumServers.random() - NodeParams.Chain.Testnet -> testnetElectrumServers.random() - NodeParams.Chain.Regtest -> platformElectrumRegtestConf() + Chain.Mainnet -> mainnetElectrumServers.random() + Chain.Testnet -> testnetElectrumServers.random() + Chain.Signet -> TODO() + Chain.Regtest -> platformElectrumRegtestConf() } /** The flow containing the electrum header responses messages. */ diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/BalanceManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/BalanceManager.kt index 91d87393c..9edc3bbe9 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/BalanceManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/BalanceManager.kt @@ -86,13 +86,9 @@ class BalanceManager( */ private suspend fun monitorSwapInBalance(peer: Peer) { val swapInParams = peer.walletParams.swapInParams - combine(peer.currentTipFlow.filterNotNull(), peer.channelsFlow, peer.swapInWallet.walletStateFlow) { (currentBlockHeight, _), channels, swapInWallet -> + combine(peer.currentTipFlow.filterNotNull(), peer.channelsFlow, peer.swapInWallet.wallet.walletStateFlow) { (currentBlockHeight, _), channels, swapInWallet -> val reservedInputs = SwapInManager.reservedWalletInputs(channels.values.filterIsInstance()) - val walletWithoutReserved = WalletState( - addresses = swapInWallet.addresses.map { (address, unspent) -> - address to unspent.filterNot { reservedInputs.contains(it.outPoint) } - }.toMap().filter { it.value.isNotEmpty() }, - ) + val walletWithoutReserved = swapInWallet.withoutReservedUtxos(reservedInputs) walletWithoutReserved.withConfirmations( currentBlockHeight = currentBlockHeight, swapInParams = swapInParams diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/DatabaseManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/DatabaseManager.kt index ab6563f47..1a0cee4b8 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/DatabaseManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/DatabaseManager.kt @@ -1,10 +1,7 @@ package fr.acinq.phoenix.managers -import co.touchlab.kermit.Logger -import co.touchlab.kermit.Severity -import co.touchlab.kermit.StaticConfig +import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.byteVector -import fr.acinq.lightning.NodeParams import fr.acinq.lightning.db.ChannelsDb import fr.acinq.lightning.db.Databases import fr.acinq.lightning.db.PaymentsDb @@ -24,7 +21,7 @@ import kotlinx.coroutines.launch class DatabaseManager( loggerFactory: LoggerFactory, private val ctx: PlatformContext, - private val chain: NodeParams.Chain, + private val chain: Chain, private val nodeParamsManager: NodeParamsManager, private val currencyManager: CurrencyManager ) : CoroutineScope by MainScope() { diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/LnurlManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/LnurlManager.kt index 08aaa625b..70222be72 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/LnurlManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/LnurlManager.kt @@ -110,8 +110,8 @@ class LnurlManager( val invoice = LnurlPay.parseLnurlPayInvoice(intent, origin, json) // SPECS: LN WALLET verifies that the amount in the provided invoice equals the amount previously specified by user. - if (amount != invoice.paymentRequest.amount) { - log.error { "rejecting invoice from $origin with amount_invoice=${invoice.paymentRequest.amount} requested_amount=$amount" } + if (amount != invoice.invoice.amount) { + log.error { "rejecting invoice from $origin with amount_invoice=${invoice.invoice.amount} requested_amount=$amount" } throw LnurlError.Pay.Invoice.InvalidAmount(origin) } 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 e8fd40784..d9de1e408 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 @@ -16,6 +16,7 @@ package fr.acinq.phoenix.managers +import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.PublicKey import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.NodeParams @@ -36,7 +37,7 @@ import kotlinx.coroutines.launch class NodeParamsManager( loggerFactory: LoggerFactory, - chain: NodeParams.Chain, + chain: Chain, walletManager: WalletManager, appConfigurationManager: AppConfigurationManager, ) : CoroutineScope by MainScope() { @@ -64,7 +65,6 @@ class NodeParamsManager( loggerFactory = loggerFactory, keyManager = keyManager, ).copy( - alias = "phoenix", zeroConfPeers = setOf(trampolineNodeId), liquidityPolicy = MutableStateFlow(startupParams.liquidityPolicy), ) @@ -79,7 +79,7 @@ class NodeParamsManager( } companion object { - val chain = NodeParams.Chain.Testnet + val chain = Chain.Testnet val trampolineNodeId = PublicKey.fromHex("03933884aaf1d6b108397e5efe5c86bcf2d8ca8d2f700eda99db9214fc2712b134") val trampolineNodeUri = NodeUri(id = trampolineNodeId, "13.248.222.197", 9735) const val remoteSwapInXpub = "tpubDAmCFB21J9ExKBRPDcVxSvGs9jtcf8U1wWWbS1xTYmnUsuUHPCoFdCnEGxLE3THSWcQE48GHJnyz8XPbYUivBMbLSMBifFd3G9KmafkM9og" diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PeerManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PeerManager.kt index 168bd8af0..d6c2aa596 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 @@ -77,7 +77,7 @@ class PeerManager( /** Flow of the peer's final wallet [WalletState.WalletWithConfirmations]. */ @OptIn(ExperimentalCoroutinesApi::class) val finalWallet = peerState.filterNotNull().flatMapLatest { peer -> - combine(peer.currentTipFlow.filterNotNull(), peer.finalWallet.walletStateFlow) { (currentBlockHeight, _), wallet -> + combine(peer.currentTipFlow.filterNotNull(), peer.finalWallet.wallet.walletStateFlow) { (currentBlockHeight, _), wallet -> wallet.withConfirmations( currentBlockHeight = currentBlockHeight, // the final wallet does not need to distinguish between weak/deep/locked txs @@ -97,7 +97,7 @@ class PeerManager( /** Flow of the peer's swap-in wallet [WalletState.WalletWithConfirmations]. */ @OptIn(ExperimentalCoroutinesApi::class) val swapInWallet = peerState.filterNotNull().flatMapLatest { peer -> - combine(peer.currentTipFlow.filterNotNull(), peer.swapInWallet.walletStateFlow) { (currentBlockHeight, _), wallet -> + combine(peer.currentTipFlow.filterNotNull(), peer.swapInWallet.wallet.walletStateFlow) { (currentBlockHeight, _), wallet -> wallet.withConfirmations( currentBlockHeight = currentBlockHeight, swapInParams = peer.walletParams.swapInParams diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/WalletManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/WalletManager.kt index 952f4ba76..02b2ba51e 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/WalletManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/WalletManager.kt @@ -17,7 +17,6 @@ package fr.acinq.phoenix.managers import fr.acinq.bitcoin.* -import fr.acinq.lightning.NodeParams import fr.acinq.lightning.crypto.KeyManager import fr.acinq.lightning.crypto.LocalKeyManager import fr.acinq.lightning.crypto.div @@ -26,7 +25,7 @@ import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.* class WalletManager( - private val chain: NodeParams.Chain + private val chain: Chain ) : CoroutineScope by MainScope() { private val _localKeyManager = MutableStateFlow(null) @@ -98,7 +97,7 @@ fun LocalKeyManager.cloudKeyHash(): String { return Crypto.hash160(cloudKey()).byteVector().toHex() } -fun LocalKeyManager.isMainnet() = chain == NodeParams.Chain.Mainnet +fun LocalKeyManager.isMainnet() = chain == Chain.Mainnet val LocalKeyManager.finalOnChainWalletPath: String get() = (KeyManager.Bip84OnChainKeys.bip84BasePath(chain) / finalOnChainWallet.account).toString() 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 1fb51a6a6..4c441e9c8 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 @@ -1,10 +1,10 @@ package fr.acinq.phoenix.utils +import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.TxId -import fr.acinq.lightning.NodeParams -class BlockchainExplorer(private val chain: NodeParams.Chain) { +class BlockchainExplorer(private val chain: Chain) { sealed class Website(val base: String) { object MempoolSpace: Website("https://mempool.space") @@ -15,16 +15,18 @@ class BlockchainExplorer(private val chain: NodeParams.Chain) { return when (website) { Website.MempoolSpace -> { when (chain) { - NodeParams.Chain.Mainnet -> "${website.base}/tx/$txId" - NodeParams.Chain.Testnet -> "${website.base}/testnet/tx/$txId" - NodeParams.Chain.Regtest -> "${website.base}/_REGTEST_/tx/$txId" + Chain.Mainnet -> "${website.base}/tx/$txId" + Chain.Testnet -> "${website.base}/testnet/tx/$txId" + Chain.Signet -> "${website.base}/signet/tx/$txId" + Chain.Regtest -> "${website.base}/_REGTEST_/tx/$txId" } } Website.BlockstreamInfo -> { when (chain) { - NodeParams.Chain.Mainnet -> "${website.base}/tx/$txId" - NodeParams.Chain.Testnet -> "${website.base}/testnet/tx/$txId" - NodeParams.Chain.Regtest -> "${website.base}/_REGTEST_/tx/$txId" + Chain.Mainnet -> "${website.base}/tx/$txId" + Chain.Testnet -> "${website.base}/testnet/tx/$txId" + Chain.Signet -> "${website.base}/signet/tx/$txId" + Chain.Regtest -> "${website.base}/_REGTEST_/tx/$txId" } } } @@ -34,16 +36,18 @@ class BlockchainExplorer(private val chain: NodeParams.Chain) { return when (website) { Website.MempoolSpace -> { when (chain) { - NodeParams.Chain.Mainnet -> "${website.base}/address/$addr" - NodeParams.Chain.Testnet -> "${website.base}/testnet/address/$addr" - NodeParams.Chain.Regtest -> "${website.base}/_REGTEST_/address/$addr" + Chain.Mainnet -> "${website.base}/address/$addr" + Chain.Testnet -> "${website.base}/testnet/address/$addr" + Chain.Signet -> "${website.base}/signet/address/$addr" + Chain.Regtest -> "${website.base}/_REGTEST_/address/$addr" } } Website.BlockstreamInfo -> { when (chain) { - NodeParams.Chain.Mainnet -> "${website.base}/address/$addr" - NodeParams.Chain.Testnet -> "${website.base}/testnet/address/$addr" - NodeParams.Chain.Regtest -> "${website.base}/_REGTEST_/address/$addr" + Chain.Mainnet -> "${website.base}/address/$addr" + Chain.Testnet -> "${website.base}/testnet/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 a9b8eb0e1..a2943c4f9 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,10 @@ package fr.acinq.phoenix.utils import fr.acinq.lightning.db.* +import fr.acinq.lightning.payment.Bolt11Invoice +import fr.acinq.lightning.payment.Bolt12Invoice import fr.acinq.phoenix.data.WalletPaymentInfo +import fr.acinq.phoenix.utils.extensions.nodeId import kotlinx.datetime.Instant class CsvWriter { @@ -11,7 +14,6 @@ class CsvWriter { val includesDescription: Boolean, val includesNotes: Boolean, val includesOriginDestination: Boolean, - val swapInAddress: String ) companion object { @@ -124,11 +126,7 @@ class CsvWriter { is IncomingPayment.Origin.KeySend -> "Incoming LN payment (keysend)" is IncomingPayment.Origin.SwapIn -> "Swap-in to ${origin.address ?: "N/A"}" is IncomingPayment.Origin.OnChain -> { - // append txs ids if any, nothing otherwise - val inputs = origin.localInputs.takeIf { it.isNotEmpty() }?.joinToString("\n- ") { - it.txid.toString() - }?.let { "\n$it" } ?: "" - "Swap-in to ${config.swapInAddress}$inputs" + "Swap-in with inputs: ${origin.localInputs.map { it.txid.toString() } }" } } is LightningOutgoingPayment -> when (val details = payment.details) { diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/Parser.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/Parser.kt index eb8c8dd49..68885999a 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/Parser.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/Parser.kt @@ -18,9 +18,8 @@ package fr.acinq.phoenix.utils import fr.acinq.bitcoin.* import fr.acinq.bitcoin.utils.Either -import fr.acinq.lightning.NodeParams -import fr.acinq.lightning.payment.PaymentRequest -import fr.acinq.lightning.utils.Try +import fr.acinq.bitcoin.utils.Try +import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.utils.sat import fr.acinq.phoenix.data.* import io.ktor.http.* @@ -69,9 +68,9 @@ object Parser { } /** Reads a payment request after stripping prefixes. Return null if input is invalid. */ - fun readPaymentRequest( + fun readBolt11Invoice( input: String - ): PaymentRequest? = when (val res = PaymentRequest.read(trimMatchingPrefix(removeExcessInput(input), lightningPrefixes))) { + ): Bolt11Invoice? = when (val res = Bolt11Invoice.read(trimMatchingPrefix(removeExcessInput(input), lightningPrefixes))) { is Try.Success -> res.get() is Try.Failure -> null } @@ -83,14 +82,14 @@ object Parser { * @param input can range from a basic bitcoin address to a sophisticated Bitcoin URI with a prefix and parameters. */ fun readBitcoinAddress( - chain: NodeParams.Chain, + chain: Chain, input: String - ): Either { + ): Either { val cleanInput = removeExcessInput(input) val url = try { Url(cleanInput) } catch (e: Exception) { - return Either.Left(BitcoinAddressError.UnknownFormat) + return Either.Left(BitcoinUriError.InvalidUri) } // -- get address @@ -101,7 +100,7 @@ object Parser { // -- read parameters val requiredParams = url.parameters.entries().filter { it.key.startsWith("req-") }.map { it.key to it.value.joinToString(";") } if (requiredParams.isNotEmpty()) { - return Either.Left(BitcoinAddressError.UnhandledRequiredParams(requiredParams)) + return Either.Left(BitcoinUriError.UnhandledRequiredParams(requiredParams)) } val amountSplit = url.parameters["amount"]?.trim()?.split(".", ignoreCase = true, limit = 2) @@ -117,7 +116,7 @@ object Parser { val label = url.parameters["label"] val message = url.parameters["message"] val lightning = url.parameters["lightning"]?.let { - when (val res = PaymentRequest.read(it)) { + when (val res = Bolt11Invoice.read(it)) { is Try.Success -> res.get() is Try.Failure -> null } @@ -128,26 +127,14 @@ object Parser { }) }.build() - return when (Bitcoin.addressToPublicKeyScript(chain.chainHash, address)) { - is AddressToPublicKeyScriptResult.Success -> { - Either.Right(BitcoinUri(chain, address, label, message, amount, lightning, otherParams)) - } - AddressToPublicKeyScriptResult.Failure.ChainHashMismatch -> { - Either.Left(BitcoinAddressError.ChainMismatch(chain)) - } - AddressToPublicKeyScriptResult.Failure.InvalidAddress, AddressToPublicKeyScriptResult.Failure.InvalidBech32Address, - is AddressToPublicKeyScriptResult.Failure.InvalidWitnessVersion -> { - Either.Left(BitcoinAddressError.UnknownFormat) - } + return when (val res = Bitcoin.addressToPublicKeyScript(chain.chainHash, address)) { + is Either.Left -> Either.Left(BitcoinUriError.InvalidScript(res.left)) + is Either.Right -> Either.Right(BitcoinUri(chain, address, res.right.let { Script.write(it) }.byteVector(), label, message, amount, lightning, otherParams)) } } /** Transforms a bitcoin address into a public key script if valid, otherwise returns null. */ - fun addressToPublicKeyScript(chain: NodeParams.Chain, address: String): ByteArray? { - return readBitcoinAddress(chain, address).right?.let { - Bitcoin.addressToPublicKeyScript(chain.chainHash, it.address).result - }?.let { - Script.write(it) - } + fun addressToPublicKeyScriptOrNull(chain: Chain, address: String): ByteVector? { + return readBitcoinAddress(chain, address).right?.script } } \ 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 ae2608593..04b33677e 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 @@ -16,8 +16,10 @@ package fr.acinq.phoenix.utils.extensions -import fr.acinq.lightning.NodeParams +import fr.acinq.bitcoin.Bitcoin import fr.acinq.lightning.db.* +import fr.acinq.lightning.payment.Bolt11Invoice +import fr.acinq.lightning.payment.Bolt12Invoice import fr.acinq.lightning.payment.PaymentRequest /** Standardized location for extending types from: fr.acinq.lightning. */ @@ -72,24 +74,3 @@ fun WalletPayment.errorMessage(): String? = when (this) { } is IncomingPayment -> null } - -/** - * In Objective-C, the function name `description()` is already in use (part of NSObject). - * So we need to alias it. - */ -fun PaymentRequest.desc(): String? = this.description - -/** - * Since unix epoch - */ -fun PaymentRequest.expiryTimestampSeconds(): Long? = this.expirySeconds?.let { - this.timestampSeconds + it -} - -val PaymentRequest.chain: NodeParams.Chain - get() = when (prefix) { - "lnbc" -> NodeParams.Chain.Mainnet - "lntb" -> NodeParams.Chain.Testnet - "lnbcrt" -> NodeParams.Chain.Regtest - else -> throw IllegalArgumentException("unhandled invoice prefix=$prefix") - } 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 46dcb0b39..6074fdcb6 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 @@ -16,8 +16,42 @@ package fr.acinq.phoenix.utils.extensions +import fr.acinq.bitcoin.Chain +import fr.acinq.bitcoin.PublicKey import fr.acinq.lightning.Feature -import fr.acinq.lightning.Features +import fr.acinq.lightning.payment.Bolt11Invoice +import fr.acinq.lightning.payment.Bolt12Invoice import fr.acinq.lightning.payment.PaymentRequest -fun PaymentRequest.isAmountlessTrampoline() = amount == null && !Features(features).hasFeature(Feature.TrampolinePayment) \ No newline at end of file +fun Bolt11Invoice.isAmountlessTrampoline() = this.amount == null && this.features.hasFeature(Feature.TrampolinePayment) + +/** + * In Objective-C, the function name `description()` is already in use (part of NSObject). + * So we need to alias it. + */ +fun Bolt11Invoice.desc(): String? = this.description + +val PaymentRequest.chain: Chain + get() = when (this) { + is Bolt11Invoice -> { + when (prefix) { + "lnbc" -> Chain.Mainnet + "lntb" -> Chain.Testnet + "lnbcrt" -> Chain.Regtest + else -> throw IllegalArgumentException("unhandled invoice prefix=$prefix") + } + } + is Bolt12Invoice -> TODO() + } + +val PaymentRequest.nodeId: PublicKey + get() = when (this) { + is Bolt11Invoice -> this.nodeId + is Bolt12Invoice -> this.nodeId + } + +val PaymentRequest.desc: String? + get() = when (this) { + is Bolt11Invoice -> this.description + is Bolt12Invoice -> this.description + } \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/migrations/IosMigrationHelper.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/migrations/IosMigrationHelper.kt index f7d2ae17f..4b50411b0 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/migrations/IosMigrationHelper.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/migrations/IosMigrationHelper.kt @@ -89,8 +89,8 @@ object IosMigrationHelper { val log = loggerFactory.newLogger(this::class) val peer = peerManager.getPeer() - val swapInAddress = peer.swapInAddress - val closingScript = Parser.addressToPublicKeyScript(chain, swapInAddress) + val swapInAddress = peer.swapInWallet.swapInAddressFlow.filterNotNull().first().first + val closingScript = Parser.addressToPublicKeyScriptOrNull(chain, swapInAddress) if (closingScript == null) { log.warning { "aborting: could not get a valid closing script" } return IosMigrationResult.Failure.InvalidClosingScript @@ -113,7 +113,7 @@ object IosMigrationHelper { log.info { "migrating ${channelsToMigrate.size} channels to $swapInAddress" } // Close all channels in parallel val command = ChannelCommand.Close.MutualClose( - scriptPubKey = ByteVector(closingScript), + scriptPubKey = closingScript, feerates = null ) channelsToMigrate.forEach { @@ -136,10 +136,10 @@ object IosMigrationHelper { log.info { "txid=${closingTx.tx.txid} ignored (dust)" } } } - log.info { "${closingTxs.size} channels closed to ${closingScript.byteVector().toHex()}" } + log.info { "${closingTxs.size} channels closed to ${closingScript.toHex()}" } // Wait for all UTXOs to arrive in swap-in wallet. - peer.swapInWallet.walletStateFlow + peer.swapInWallet.wallet.walletStateFlow .map { it.utxos.map { it.outPoint.txid } } .first { txidsInWallet -> closingTxs.values.all { txid -> txidsInWallet.contains(txid) } } log.info { "all mutual-close txids found in swap-in wallet" } diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/TestConstants.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/TestConstants.kt index d32d3ee28..0a8187d3c 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/TestConstants.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/TestConstants.kt @@ -16,9 +16,8 @@ package fr.acinq.phoenix -import fr.acinq.bitcoin.Block +import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.MnemonicCode -import fr.acinq.bitcoin.PublicKey import fr.acinq.lightning.* import fr.acinq.lightning.blockchain.fee.FeerateTolerance import fr.acinq.lightning.blockchain.fee.OnChainFeeConf @@ -39,13 +38,12 @@ object TestConstants { private val entropy = Hex.decode("0202020202020202020202020202020202020202020202020202020202020202") val mnemonics = MnemonicCode.toMnemonics(entropy) val seed = MnemonicCode.toSeed(mnemonics, "").toByteVector32() - val keyManager = LocalKeyManager(seed, NodeParams.Chain.Regtest, swapInServerXpub) + val keyManager = LocalKeyManager(seed, Chain.Regtest, swapInServerXpub) val nodeParams = NodeParams( - chain = NodeParams.Chain.Regtest, + chain = Chain.Regtest, loggerFactory = testLoggerFactory, keyManager = keyManager, ).copy( - alias = "bob", dustLimit = 1_000.sat, maxRemoteDustLimit = 1_500.sat, onChainFeeConf = OnChainFeeConf( @@ -58,7 +56,7 @@ object TestConstants { toRemoteDelayBlocks = CltvExpiryDelta(144), maxToLocalDelayBlocks = CltvExpiryDelta(1024), feeBase = 10.msat, - feeProportionalMillionth = 10, + feeProportionalMillionths = 10, paymentRecipientExpiryParams = RecipientCltvExpiryParams(CltvExpiryDelta(0), CltvExpiryDelta(0)), ) } 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 1bf23b22f..d29c6864c 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 @@ -17,7 +17,6 @@ package fr.acinq.phoenix.data.lnurl import fr.acinq.bitcoin.* -import fr.acinq.lightning.NodeParams import fr.acinq.lightning.crypto.LocalKeyManager import fr.acinq.lightning.utils.toByteVector import fr.acinq.secp256k1.Hex @@ -28,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, NodeParams.Chain.Testnet, remoteSwapInExtendedPublicKey = "tpubDDt5vQap1awkyDXx1z1cP7QFKSZHDCCpbU8nSq9jy7X2grTjUVZDePexf6gc6AHtRRzkgfPW87K6EKUVV6t3Hu2hg7YkHkmMeLSfrP85x41") + private val keyManager = LocalKeyManager(seed, Chain.Testnet, 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 67d1fd78f..68edda462 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 @@ -19,7 +19,7 @@ package fr.acinq.phoenix.db import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.TxId import fr.acinq.lightning.db.IncomingPayment -import fr.acinq.lightning.payment.PaymentRequest +import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat import fr.acinq.phoenix.db.payments.* @@ -35,12 +35,12 @@ class IncomingPaymentDbTypeVersionTest { val channelId1 = ByteVector32.fromValidHex("3b6208285563c9adb009781acf1626f1c2a3b1a3492d5ec312ead8282c7ad6da") val address1 = "tb1q97tpc0y4rvdnu9wm7nu354lmmzdm8du228u3g4" - val invoice1 = - PaymentRequest.read("lntb1500n1ps9u963pp5llphsu6evgmzgk8g2e73su44wn6txmwywdzwvtdwzrt9pqxc9f5sdpzxysy2umswfjhxum0yppk76twypgxzmnwvycqp7xqrrss9qy9qsqsp5qa7092geq6ptp24uzlfw0vj3w4whh2zuc9rquwca69acwx5khckqvslyw2n6dallc868vxu3uueyhw6pe00cmluynv7ca4tknz7g274rp9ucwqpx5ydejsmzl4xpegqtemcq6vwvu8alpxttlj82e7j26gspfj06gn").get() + val bolt11Invoice = + Bolt11Invoice.read("lntb1500n1ps9u963pp5llphsu6evgmzgk8g2e73su44wn6txmwywdzwvtdwzrt9pqxc9f5sdpzxysy2umswfjhxum0yppk76twypgxzmnwvycqp7xqrrss9qy9qsqsp5qa7092geq6ptp24uzlfw0vj3w4whh2zuc9rquwca69acwx5khckqvslyw2n6dallc868vxu3uueyhw6pe00cmluynv7ca4tknz7g274rp9ucwqpx5ydejsmzl4xpegqtemcq6vwvu8alpxttlj82e7j26gspfj06gn").get() @Test fun incoming_origin_invoice() { - val origin = IncomingPayment.Origin.Invoice(invoice1) + val origin = IncomingPayment.Origin.Invoice(bolt11Invoice) val deserialized = IncomingOriginData.deserialize(IncomingOriginTypeVersion.INVOICE_V0, origin.mapToDb().second) assertEquals(origin, deserialized) } diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/OutgoingPaymentDbTypeVersionTest.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/OutgoingPaymentDbTypeVersionTest.kt index 242ca76b8..dea976181 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/OutgoingPaymentDbTypeVersionTest.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/OutgoingPaymentDbTypeVersionTest.kt @@ -18,11 +18,9 @@ package fr.acinq.phoenix.db import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.channel.InvalidFinalScript -import fr.acinq.lightning.db.ChannelClosingType import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.payment.FinalFailure -import fr.acinq.lightning.payment.PaymentRequest -import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.sat import fr.acinq.lightning.wire.PermanentNodeFailure import fr.acinq.phoenix.db.payments.* @@ -34,12 +32,12 @@ class OutgoingPaymentDbTypeVersionTest { val channelId1 = randomBytes32() val address1 = "tb1q97tpc0y4rvdnu9wm7nu354lmmzdm8du228u3g4" val preimage1 = randomBytes32() - val paymentRequest1 = - PaymentRequest.read("lntb1500n1ps9utezpp5xjfvpvgg3zykv2kdd9yws86xw5ww2kr60h9yphth2h6fly87a9gqdpzxysy2umswfjhxum0yppk76twypgxzmnwvycqp7xqrrss9qy9qsqsp5vm25lch9spq2m9fxqrgcxq0mxrgaehstd9javflyadsle5d97p9qmu9zsjn7l59lmps3568tz9ppla4xhawjptjyrw32jed84fe75z0ka0kmnntc9la95acvc0mjav6rdv5037y6zq9e0eqhenlt8y0yh8cpj467cl").get() + val bolt11Invoice = + Bolt11Invoice.read("lntb1500n1ps9utezpp5xjfvpvgg3zykv2kdd9yws86xw5ww2kr60h9yphth2h6fly87a9gqdpzxysy2umswfjhxum0yppk76twypgxzmnwvycqp7xqrrss9qy9qsqsp5vm25lch9spq2m9fxqrgcxq0mxrgaehstd9javflyadsle5d97p9qmu9zsjn7l59lmps3568tz9ppla4xhawjptjyrw32jed84fe75z0ka0kmnntc9la95acvc0mjav6rdv5037y6zq9e0eqhenlt8y0yh8cpj467cl").get() @Test fun outgoing_details_normal() { - val details = LightningOutgoingPayment.Details.Normal(paymentRequest1) + val details = LightningOutgoingPayment.Details.Normal(bolt11Invoice) val deserialized = OutgoingDetailsData.deserialize(OutgoingDetailsTypeVersion.NORMAL_V0, details.mapToDb().second) assertEquals(details, deserialized) } @@ -53,7 +51,7 @@ class OutgoingPaymentDbTypeVersionTest { @Test fun outgoing_details_swapout() { - val details = LightningOutgoingPayment.Details.SwapOut(address1, paymentRequest1, 1_000.sat) + val details = LightningOutgoingPayment.Details.SwapOut(address1, bolt11Invoice, 1_000.sat) val deserialized = OutgoingDetailsData.deserialize(OutgoingDetailsTypeVersion.SWAPOUT_V0, details.mapToDb().second) assertEquals(details, deserialized) } 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 7481789c6..7749f904b 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 @@ -18,10 +18,12 @@ package fr.acinq.phoenix.db import com.squareup.sqldelight.db.SqlDriver 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 @@ -142,7 +144,7 @@ class SqlitePaymentsDatabaseTest { @Test fun incoming__is_expired() = runTest { val expiredInvoice = - PaymentRequest.read("lntb1p0ufamxpp5l23zy5f8h2dcr8hxynptkcyuzdygy36pz76hgayp7n9q45a3cwuqdqqxqyjw5q9qtzqqqqqq9qsqsp5vusneyeywvawt4d7sslx3kx0eh7kk68l7j26qr0ge7z04lxhe5ssrzjqwfn3p9278ttzzpe0e00uhyxhned3j5d9acqak5emwfpflp8z2cnfluw6cwxn8wdcyqqqqlgqqqqqeqqjqmjvx0y3cfw54syp4jqw6jlj73qt97vxftjd3w3ywx6v2jqkdx9uxw3hk9qq6st9qyfpu3nzrpefwye63vmnyyzn6z8n7nkqsjj6lsaspu2p3mm").get() + Bolt11Invoice.read("lntb1p0ufamxpp5l23zy5f8h2dcr8hxynptkcyuzdygy36pz76hgayp7n9q45a3cwuqdqqxqyjw5q9qtzqqqqqq9qsqsp5vusneyeywvawt4d7sslx3kx0eh7kk68l7j26qr0ge7z04lxhe5ssrzjqwfn3p9278ttzzpe0e00uhyxhned3j5d9acqak5emwfpflp8z2cnfluw6cwxn8wdcyqqqqlgqqqqqeqqjqmjvx0y3cfw54syp4jqw6jlj73qt97vxftjd3w3ywx6v2jqkdx9uxw3hk9qq6st9qyfpu3nzrpefwye63vmnyyzn6z8n7nkqsjj6lsaspu2p3mm").get() db.addIncomingPayment(preimage1, IncomingPayment.Origin.Invoice(expiredInvoice), 0) db.receivePayment(paymentHash1, receivedWith1, 10) assertTrue(db.getIncomingPayment(paymentHash1)!!.isExpired()) @@ -151,7 +153,7 @@ class SqlitePaymentsDatabaseTest { @Test fun incoming__purge_expired() = runTest { val expiredPreimage = randomBytes32() - val expiredInvoice = PaymentRequest.create( + val expiredInvoice = Bolt11Invoice.create( chainHash = Block.TestnetGenesisBlock.hash, amount = 150_000.msat, paymentHash = Crypto.sha256(expiredPreimage).toByteVector32(), @@ -403,8 +405,8 @@ class SqlitePaymentsDatabaseTest { private fun createInvoice( preimage: ByteVector32, msat: MilliSatoshi = 150_000.msat - ): PaymentRequest { - return PaymentRequest.create( + ): Bolt11Invoice { + return Bolt11Invoice.create( chainHash = Block.LivenetGenesisBlock.hash, amount = msat, paymentHash = Crypto.sha256(preimage).toByteVector32(), 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 531a61718..dc5630c7e 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 @@ -1,12 +1,13 @@ package fr.acinq.phoenix.db.cloud import fr.acinq.bitcoin.* +import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.Lightning.randomKey import fr.acinq.lightning.db.* +import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.payment.FinalFailure -import fr.acinq.lightning.payment.PaymentRequest import fr.acinq.lightning.utils.* import fr.acinq.phoenix.runTest import fr.acinq.secp256k1.Hex @@ -60,7 +61,7 @@ class CloudDataTest { @Test fun incoming__invoice() = runTest { - val invoice = createInvoice(preimage, 250_000.msat) + val invoice = createBolt11Invoice(preimage, 250_000.msat) testRoundtrip( IncomingPayment( preimage = preimage, @@ -94,7 +95,7 @@ class CloudDataTest { @Test fun incoming__receivedWith_lightning() = runTest { - val invoice = createInvoice(preimage, 250_000.msat) + val invoice = createBolt11Invoice(preimage, 250_000.msat) val receivedWith1 = IncomingPayment.ReceivedWith.LightningPayment( amount = 100_000.msat, channelId = channelId, htlcId = 1L ) @@ -112,7 +113,7 @@ class CloudDataTest { @Test fun incoming__receivedWith_newChannel() = runTest { - val invoice = createInvoice(preimage, 10_000_000.msat) + 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 ) @@ -149,7 +150,7 @@ class CloudDataTest { @Test fun outgoing__normal() = runTest { - val invoice = createInvoice(preimage, 1_000_000.msat) + val invoice = createBolt11Invoice(preimage, 1_000_000.msat) testRoundtrip( LightningOutgoingPayment( id = uuid, @@ -176,7 +177,7 @@ class CloudDataTest { @Test fun outgoing__swapOut() = runTest { - val invoice = createInvoice(preimage, 1_000_000.msat) + val invoice = createBolt11Invoice(preimage, 1_000_000.msat) testRoundtrip( LightningOutgoingPayment( id = uuid, @@ -230,7 +231,7 @@ class CloudDataTest { @Test fun outgoing__failed() = runTest { val recipientAmount = 500_000.msat - val invoice = createInvoice(preimage, recipientAmount) + val invoice = createBolt11Invoice(preimage, recipientAmount) val (a, b) = listOf(randomKey().publicKey(), randomKey().publicKey()) val part = LightningOutgoingPayment.Part( id = UUID.randomUUID(), @@ -266,7 +267,7 @@ class CloudDataTest { @Test fun outgoing__succeeded_offChain() = runTest { val recipientAmount = 500_000.msat - val invoice = createInvoice(preimage, recipientAmount) + val invoice = createBolt11Invoice(preimage, recipientAmount) val (a, b) = listOf(randomKey().publicKey(), randomKey().publicKey()) val part1 = LightningOutgoingPayment.Part( id = UUID.randomUUID(), @@ -421,8 +422,8 @@ class CloudDataTest { Feature.BasicMultiPartPayment to FeatureSupport.Optional ) - private fun createInvoice(preimage: ByteVector32, amount: MilliSatoshi): PaymentRequest { - return PaymentRequest.create( + private fun createBolt11Invoice(preimage: ByteVector32, amount: MilliSatoshi): Bolt11Invoice { + return Bolt11Invoice.create( chainHash = Block.LivenetGenesisBlock.hash, amount = amount, paymentHash = Crypto.sha256(preimage).toByteVector32(), 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 0fdd9b1c3..46de3a343 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 @@ -1,9 +1,11 @@ package fr.acinq.phoenix.utils import fr.acinq.bitcoin.Block +import fr.acinq.bitcoin.OutPoint import fr.acinq.bitcoin.PrivateKey import fr.acinq.bitcoin.PublicKey import fr.acinq.bitcoin.TxId +import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.CltvExpiryDelta import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.MilliSatoshi @@ -11,8 +13,8 @@ import fr.acinq.lightning.db.ChannelCloseOutgoingPayment import fr.acinq.lightning.db.ChannelClosingType import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.payment.PaymentRequest -import fr.acinq.lightning.utils.Either import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.currentTimestampSeconds import fr.acinq.lightning.utils.msat @@ -228,9 +230,10 @@ class CsvWriterTests { @Test fun testRow_Incoming_NewChannel_DualSwapIn() { + val input = OutPoint(TxId(randomBytes32()), 0) val payment = IncomingPayment( preimage = randomBytes32(), - origin = IncomingPayment.Origin.OnChain(txId = TxId(randomBytes32()), localInputs = setOf()), + origin = IncomingPayment.Origin.OnChain(txId = TxId(randomBytes32()), localInputs = setOf(input)), received = IncomingPayment.Received( receivedWith = listOf( IncomingPayment.ReceivedWith.NewChannel( @@ -252,7 +255,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 to tb1qf72v4qyczf7ymmqtr8z3vfqn6dapzl3e7l6tjv,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,Swap-in with inputs: [${input.txid}],L1 Top-up,Via dual-funding flow\r\n" val actual = CsvWriter.makeRow( info = WalletPaymentInfo(payment, metadata, WalletPaymentFetchOptions.All), localizedDescription = "L1 Top-up", @@ -332,7 +335,7 @@ class CsvWriterTests { * So everything else can be fake. */ private fun makePaymentRequest() = - PaymentRequest.create( + Bolt11Invoice.create( chainHash = Block.TestnetGenesisBlock.hash, amount = 10_000.msat, paymentHash = randomBytes32(), @@ -379,6 +382,5 @@ class CsvWriterTests { includesDescription = true, includesNotes = true, includesOriginDestination = true, - swapInAddress = swapInAddress ) } \ No newline at end of file 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 4505db22e..33a245e70 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 @@ -17,214 +17,211 @@ package fr.acinq.phoenix.utils +import fr.acinq.bitcoin.Bitcoin +import fr.acinq.bitcoin.Chain +import fr.acinq.bitcoin.BitcoinError +import fr.acinq.bitcoin.ByteVector import fr.acinq.bitcoin.utils.Either -import fr.acinq.lightning.NodeParams +import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.payment.PaymentRequest import fr.acinq.lightning.utils.sat -import fr.acinq.phoenix.data.BitcoinAddressError +import fr.acinq.phoenix.data.BitcoinUriError import fr.acinq.phoenix.data.BitcoinUri import io.ktor.http.* -import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs class ParserTest { @Test fun parse_bitcoin_uri_with_valid_addresses() { - listOf>>( - "17VZNX1SN5NtKa8UQFxwQbFeFc3iqRYhem" to Either.Right( - BitcoinUri(NodeParams.Chain.Mainnet, "17VZNX1SN5NtKa8UQFxwQbFeFc3iqRYhem") - ), - "3EktnHQD7RiAE6uzMj2ZifT9YgRrkSgzQX" to Either.Right( - BitcoinUri(NodeParams.Chain.Mainnet, "3EktnHQD7RiAE6uzMj2ZifT9YgRrkSgzQX") - ), - "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" to Either.Right( - BitcoinUri(NodeParams.Chain.Mainnet, "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") - ), - "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3" to Either.Right( - BitcoinUri(NodeParams.Chain.Mainnet, "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3") - ), - ).forEach { - assertEquals(it.second, Parser.readBitcoinAddress(NodeParams.Chain.Mainnet, it.first)) - } + assertIs>(Parser.readBitcoinAddress(Chain.Mainnet, "17VZNX1SN5NtKa8UQFxwQbFeFc3iqRYhem")) + assertIs>(Parser.readBitcoinAddress(Chain.Mainnet, "3EktnHQD7RiAE6uzMj2ZifT9YgRrkSgzQX")) + assertIs>(Parser.readBitcoinAddress(Chain.Mainnet, "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")) + assertIs>(Parser.readBitcoinAddress(Chain.Mainnet, "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3")) - listOf>>( - "mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn" to Either.Right( - BitcoinUri(NodeParams.Chain.Testnet, "mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn") - ), - "2MzQwSSnBHWHqSAqtTVQ6v47XtaisrJa1Vc" to Either.Right( - BitcoinUri(NodeParams.Chain.Testnet, "2MzQwSSnBHWHqSAqtTVQ6v47XtaisrJa1Vc") - ), - "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx" to Either.Right( - BitcoinUri(NodeParams.Chain.Testnet, "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx") - ), - "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7" to Either.Right( - BitcoinUri(NodeParams.Chain.Testnet, "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7") - ), - "tb1p607g5ea77m370pey3y5rg58fz7542hnpg40rs2cqw6w69yt5lf2qlktj2a" to Either.Right( - BitcoinUri(NodeParams.Chain.Testnet, "tb1p607g5ea77m370pey3y5rg58fz7542hnpg40rs2cqw6w69yt5lf2qlktj2a") - ), - ).forEach { - assertEquals(it.second, Parser.readBitcoinAddress(NodeParams.Chain.Testnet, it.first)) - } + assertIs>(Parser.readBitcoinAddress(Chain.Testnet, "mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn")) + assertIs>(Parser.readBitcoinAddress(Chain.Testnet, "2MzQwSSnBHWHqSAqtTVQ6v47XtaisrJa1Vc")) + assertIs>(Parser.readBitcoinAddress(Chain.Testnet, "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx")) + assertIs>(Parser.readBitcoinAddress(Chain.Testnet, "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7")) + assertIs>(Parser.readBitcoinAddress(Chain.Testnet, "tb1p607g5ea77m370pey3y5rg58fz7542hnpg40rs2cqw6w69yt5lf2qlktj2a")) } - // TODO enable it again once bitcoin-lib parser returns typed errors - @Ignore + @Test fun parse_bitcoin_uri_chain_mismatch() { assertEquals( - expected = Either.Left(BitcoinAddressError.ChainMismatch(expected = NodeParams.Chain.Testnet)), - actual = Parser.readBitcoinAddress(NodeParams.Chain.Testnet, "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3") + expected = Either.Left(BitcoinUriError.InvalidScript(error = BitcoinError.ChainHashMismatch)), + actual = Parser.readBitcoinAddress(Chain.Testnet, "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3") ) assertEquals( - expected = Either.Left(BitcoinAddressError.ChainMismatch(expected = NodeParams.Chain.Mainnet)), - actual = Parser.readBitcoinAddress(NodeParams.Chain.Mainnet, "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx") + expected = Either.Left(BitcoinUriError.InvalidScript(error = BitcoinError.ChainHashMismatch)), + actual = Parser.readBitcoinAddress(Chain.Mainnet, "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx") ) } @Test fun parse_bitcoin_uri_with_invalid_addresses() { - listOf>>( - "17VZNX1SN5NtKa8UQFxwQbFeFc3iqRYhe" to Either.Left(BitcoinAddressError.UnknownFormat), - "btc:17VZNX1SN5NtKa8UQFxwQbFeFc3iqRYhe" to Either.Left(BitcoinAddressError.UnknownFormat), - ).forEach { - assertEquals(it.second, Parser.readBitcoinAddress(NodeParams.Chain.Mainnet, it.first)) - } + assertIs>( + Parser.readBitcoinAddress(Chain.Mainnet, "17VZNX1SN5NtKa8UQFxwQbFeFc3iqRYhe") + ) } @Test - fun parse_bitcoin_uri_with_various_prefixes() { - listOf>>( - "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" to Either.Right( - BitcoinUri(NodeParams.Chain.Mainnet, "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") - ), - "bitcoin://bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" to Either.Right( - BitcoinUri(NodeParams.Chain.Mainnet, "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") - ), - ).forEach { - assertEquals(it.second, Parser.readBitcoinAddress(NodeParams.Chain.Mainnet, it.first)) - } + fun parse_bitcoin_uri_with_bitcoin_prefixes() { + assertIs>( + Parser.readBitcoinAddress(Chain.Mainnet, "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") + ) + assertIs>( + Parser.readBitcoinAddress(Chain.Mainnet, "bitcoin://bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") + ) + } + + @Test + fun parse_bitcoin_uri_with_non_bitcoin_prefixes() { + // non-bitcoin prefixes are not trimmed, so error is invalid script + assertIs>( + Parser.readBitcoinAddress(Chain.Mainnet, "btc:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") + ) + assertIs>( + Parser.readBitcoinAddress(Chain.Mainnet, "lightning:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") + ) + assertIs>( + Parser.readBitcoinAddress(Chain.Mainnet, "lnurl://bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") + ) } @Test fun parse_bitcoin_uri_with_parameters() { - listOf>>( + listOf>>( // ignore unhandled params "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?somethingyoudontunderstand=50&somethingelseyoudontget=999" to Either.Right( BitcoinUri( - chain = NodeParams.Chain.Mainnet, + chain = Chain.Mainnet, address = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + script = ByteVector("76a91462e907b15cbf27d5425399ebf6f0fb50ebb88f1888ac"), ignoredParams = ParametersBuilder().apply { set("somethingyoudontunderstand", "50"); set("somethingelseyoudontget", "999") }.build() ) ), // ignore payjoin parameter "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?pj=https://acinq.co" to Either.Right( BitcoinUri( - chain = NodeParams.Chain.Mainnet, + chain = Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6"), ignoredParams = ParametersBuilder().apply { set("pj", "https://acinq.co") }.build() ) ), // fail if uri contains required params we don't understand "bitcoin:175tWpb8K1S7NmH4Zx6rewF9WQrcZv245W?req-somethingyoudontunderstand=50&req-somethingelseyoudontget=999" to Either.Left( - BitcoinAddressError.UnhandledRequiredParams(parameters = listOf("req-somethingyoudontunderstand" to "50", "req-somethingelseyoudontget" to "999")) + BitcoinUriError.UnhandledRequiredParams(parameters = listOf("req-somethingyoudontunderstand" to "50", "req-somethingelseyoudontget" to "999")) ), ).forEach { - assertEquals(it.second, Parser.readBitcoinAddress(NodeParams.Chain.Mainnet, it.first)) + assertEquals(it.second, Parser.readBitcoinAddress(Chain.Mainnet, it.first)) } } @Test fun parse_bitcoin_uri_with_lightning_invoice() { - listOf>>( + listOf>>( // valid lightning invoice "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?foo=bar&lightning=lntb15u1p05vazrpp5apz75ghtq3ynmc5qm98tsgucmsav44fyffpguhzdep2kcgkfme4sdq4xysyymr0vd4kzcmrd9hx7cqp2xqrrss9qy9qsqsp5v4hqr48qe0u7al6lxwdpmp3w6k7evjdavm0lh7arpv3qaf038s5st2d8k8vvmxyav2wkfym9jp4mk64srmswgh7l6sqtq7l4xl3nknf8snltamvpw5p3yl9nxg0ax9k0698rr94qx6unrv8yhccmh4z9ghcq77hxps" to Either.Right( BitcoinUri( - chain = NodeParams.Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", - paymentRequest = PaymentRequest.read("lntb15u1p05vazrpp5apz75ghtq3ynmc5qm98tsgucmsav44fyffpguhzdep2kcgkfme4sdq4xysyymr0vd4kzcmrd9hx7cqp2xqrrss9qy9qsqsp5v4hqr48qe0u7al6lxwdpmp3w6k7evjdavm0lh7arpv3qaf038s5st2d8k8vvmxyav2wkfym9jp4mk64srmswgh7l6sqtq7l4xl3nknf8snltamvpw5p3yl9nxg0ax9k0698rr94qx6unrv8yhccmh4z9ghcq77hxps").get(), + 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 = NodeParams.Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") + BitcoinUri(chain = Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6")) ), // empty lightning invoice "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?lightning=" to Either.Right( - BitcoinUri(chain = NodeParams.Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") + BitcoinUri(chain = Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6")) ), - ).forEach { - assertEquals(it.second, Parser.readBitcoinAddress(NodeParams.Chain.Mainnet, it.first)) + ).forEach { (address, expected) -> + val uri = Parser.readBitcoinAddress(Chain.Mainnet, address) + assertEquals(expected, uri) } } @Test fun parse_bitcoin_uri_with_amount() { - listOf>>( + listOf>>( "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?amount=0.0123 " to Either.Right( BitcoinUri( - chain = NodeParams.Chain.Mainnet, + chain = Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6"), amount = 12_30000.sat ) ), "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?amount=1.23456789999" to Either.Right( BitcoinUri( - chain = NodeParams.Chain.Mainnet, + chain = Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6"), amount = 1_234_56789.sat ) ), "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?amount=21000000.000" to Either.Right( BitcoinUri( - chain = NodeParams.Chain.Mainnet, + chain = Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6"), amount = 21_000_000_000_00000.sat ) ), // amount with invalid chars is ignored "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?amount=0.001a2" to Either.Right( BitcoinUri( - chain = NodeParams.Chain.Mainnet, + chain = Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6"), amount = null ) ), // amount with two decimal separators is ignored "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?amount=0.001.2" to Either.Right( BitcoinUri( - chain = NodeParams.Chain.Mainnet, + chain = Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6"), amount = null ) ), // amount with a comma separator is ignored "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?amount=0,0012" to Either.Right( BitcoinUri( - chain = NodeParams.Chain.Mainnet, + chain = Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6"), amount = null ) ), // amount < 1 sat is ignored "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?amount=0.000000001" to Either.Right( BitcoinUri( - chain = NodeParams.Chain.Mainnet, + chain = Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6"), amount = null ) ), // amount > 21e6 btc is ignored "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?amount=21000000.00000001" to Either.Right( BitcoinUri( - chain = NodeParams.Chain.Mainnet, + chain = Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6"), amount = null ) ) ).forEach { - assertEquals(it.second, Parser.readBitcoinAddress(NodeParams.Chain.Mainnet, it.first)) + assertEquals(it.second, Parser.readBitcoinAddress(Chain.Mainnet, it.first)) } } 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 33ed4d157..7a7ef298e 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 @@ -20,13 +20,13 @@ import co.touchlab.sqliter.DatabaseConfiguration import com.squareup.sqldelight.db.SqlDriver import com.squareup.sqldelight.drivers.native.NativeSqliteDriver import com.squareup.sqldelight.drivers.native.wrapConnection -import fr.acinq.lightning.NodeParams +import fr.acinq.bitcoin.Chain import fr.acinq.phoenix.utils.PlatformContext import fr.acinq.phoenix.utils.getDatabaseFilesDirectoryPath actual fun createChannelsDbDriver( ctx: PlatformContext, - chain: NodeParams.Chain, + chain: Chain, nodeIdHash: String ): SqlDriver { val schema = ChannelsDatabase.Schema @@ -55,7 +55,7 @@ actual fun createChannelsDbDriver( actual fun createPaymentsDbDriver( ctx: PlatformContext, - chain: NodeParams.Chain, + chain: Chain, nodeIdHash: String ): SqlDriver { val schema = PaymentsDatabase.Schema 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 6eec2f03b..48870c23a 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 @@ -1,14 +1,11 @@ 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.TxId -import fr.acinq.bitcoin.io.ByteArrayOutput import fr.acinq.lightning.ChannelEvents import fr.acinq.lightning.DefaultSwapInParams import fr.acinq.lightning.LiquidityEvents @@ -39,9 +36,8 @@ import fr.acinq.lightning.io.PeerEvent import fr.acinq.lightning.io.TcpSocket import fr.acinq.lightning.payment.LiquidityPolicy import fr.acinq.lightning.utils.Connection -import fr.acinq.lightning.wire.LightningCodecs import fr.acinq.lightning.wire.LiquidityAds -import fr.acinq.phoenix.db.payments.WalletPaymentMetadataRow +import fr.acinq.phoenix.PhoenixBusiness /** * Class types from lightning-kmp & bitcoin-kmp are not exported to iOS unless we explicitly @@ -465,4 +461,4 @@ suspend fun Peer._requestInboundLiquidity( } val InboundLiquidityOutgoingPayment._lease: LiquidityAds_Lease - get() = LiquidityAds_Lease(this.lease) \ No newline at end of file + get() = LiquidityAds_Lease(this.lease)