diff --git a/Dockerfile b/Dockerfile index 0a9684f28..e8cb48f80 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,9 +5,9 @@ FROM eclipse-temurin:11.0.20_8-jdk-alpine as ECLAIR_CORE_BUILD COPY ./buildSrc/src/main/kotlin/Versions.kt . RUN cat Versions.kt | grep "const val eclair =" | cut -d '"' -f 2 > eclair-core-version.txt -ARG MAVEN_VERSION=3.9.3 +ARG MAVEN_VERSION=3.9.4 ARG USER_HOME_DIR="/root" -ARG SHA=400fc5b6d000c158d5ee7937543faa06b6bda8408caa2444a9c947c21472fde0f0b64ac452b8cec8855d528c0335522ed5b6c8f77085811c7e29e1bedbb5daa2 +ARG SHA=deaa39e16b2cf20f8cd7d232a1306344f04020e1f0fb28d35492606f647a60fe729cc40d3cba33e093a17aed41bd161fe1240556d0f1b80e773abd408686217e ARG BASE_URL=https://apache.osuosl.org/maven/maven-3/${MAVEN_VERSION}/binaries RUN apk add --no-cache curl tar bash git diff --git a/phoenix-android/build.gradle.kts b/phoenix-android/build.gradle.kts index b99d91b6d..166c3face 100644 --- a/phoenix-android/build.gradle.kts +++ b/phoenix-android/build.gradle.kts @@ -24,8 +24,8 @@ android { applicationId = "fr.acinq.phoenix.mainnet" minSdk = 24 targetSdk = 33 - versionCode = 54 - versionName = "2.0.0" + versionCode = 55 + versionName = gitCommitHash() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountInput.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountInput.kt index 8f6842b61..0e3f6a97b 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountInput.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountInput.kt @@ -558,7 +558,7 @@ private fun UnitDropdown( var selectedIndex by remember { mutableStateOf(maxOf(units.lastIndexOf(selectedUnit), 0)) } Box(modifier = modifier.wrapContentSize(Alignment.TopStart)) { Button( - text = units[selectedIndex].toString(), + text = units[selectedIndex].displayCode, icon = R.drawable.ic_chevron_down, onClick = { expanded = true }, padding = internalPadding, @@ -572,14 +572,14 @@ private fun UnitDropdown( onDismiss() }, ) { - units.forEachIndexed { index, s -> + units.forEachIndexed { index, unit -> DropdownMenuItem(onClick = { selectedIndex = index expanded = false onDismiss() onUnitChange(units[index]) }) { - Text(text = s.toString()) + Text(text = unit.displayCode) } } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountView.kt index 7ae2a6957..6b2df764b 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/AmountView.kt @@ -93,7 +93,7 @@ fun AmountView( if (!isRedacted && showUnit) { Spacer(modifier = Modifier.width(separatorSpace)) Text( - text = unit.toString(), + text = unit.displayCode, style = unitTextStyle, modifier = Modifier.alignBy(FirstBaseline) ) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/Converter.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/Converter.kt index a62430f45..011c466d3 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/Converter.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/Converter.kt @@ -100,17 +100,30 @@ object Converter { /** Converts [MilliSatoshi] to a fiat amount. */ fun MilliSatoshi.toFiat(rate: Double): Double = this.toUnit(BitcoinUnit.Btc) * rate - fun Double?.toPrettyString(unit: CurrencyUnit, withUnit: Boolean = false, mSatDisplayPolicy: MSatDisplayPolicy = MSatDisplayPolicy.HIDE): String = (this?.let { amount -> - when { - unit == BitcoinUnit.Sat && amount < 1.0 && mSatDisplayPolicy == MSatDisplayPolicy.SHOW_IF_ZERO_SATS -> { - SAT_FORMAT_WITH_MILLIS.format(amount) + fun Double?.toPrettyString( + unit: CurrencyUnit, + withUnit: Boolean = false, + mSatDisplayPolicy: MSatDisplayPolicy = MSatDisplayPolicy.HIDE, + ): String { + val amount = this?.let { + when { + unit == BitcoinUnit.Sat && it < 1.0 && mSatDisplayPolicy == MSatDisplayPolicy.SHOW_IF_ZERO_SATS -> { + SAT_FORMAT_WITH_MILLIS.format(it) + } + unit is BitcoinUnit -> { + getCoinFormat(unit, withMillis = mSatDisplayPolicy == MSatDisplayPolicy.SHOW).format(it) + } + unit is FiatCurrency -> { + it.takeIf { it >= 0 }?.let { FIAT_FORMAT.format(it) } + } + else -> "?!" } - unit is BitcoinUnit -> getCoinFormat(unit, withMillis = mSatDisplayPolicy == MSatDisplayPolicy.SHOW).format(amount) - unit is FiatCurrency -> amount.takeIf { it >= 0 }?.let { FIAT_FORMAT.format(it) } - else -> "?!" + } ?: "N/A" + return if (withUnit) { + "$amount ${unit.displayCode}" + } else { + amount } - } ?: "N/A").run { - if (withUnit) "$this $unit" else this } fun MilliSatoshi.toPrettyStringWithFallback(unit: CurrencyUnit, rate: ExchangeRate.BitcoinPriceRate? = null, withUnit: Boolean = false, mSatDisplayPolicy: MSatDisplayPolicy = MSatDisplayPolicy.HIDE): String { 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 84edd3f9c..33713c628 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 @@ -58,11 +58,11 @@ object UserPrefs { // -- unit, fiat, conversion... private val BITCOIN_UNIT = stringPreferencesKey("BITCOIN_UNIT") - fun getBitcoinUnit(context: Context): Flow = prefs(context).map { BitcoinUnit.valueOf(it[BITCOIN_UNIT] ?: BitcoinUnit.Sat.name) } + fun getBitcoinUnit(context: Context): Flow = prefs(context).map { it[BITCOIN_UNIT]?.let { BitcoinUnit.valueOfOrNull(it) } ?: BitcoinUnit.Sat } suspend fun saveBitcoinUnit(context: Context, coinUnit: BitcoinUnit) = context.userPrefs.edit { it[BITCOIN_UNIT] = coinUnit.name } private val FIAT_CURRENCY = stringPreferencesKey("FIAT_CURRENCY") - fun getFiatCurrency(context: Context): Flow = prefs(context).map { FiatCurrency.valueOf(it[FIAT_CURRENCY] ?: FiatCurrency.USD.name) } + fun getFiatCurrency(context: Context): Flow = prefs(context).map { it[FIAT_CURRENCY]?.let { FiatCurrency.valueOfOrNull(it) } ?: FiatCurrency.USD } suspend fun saveFiatCurrency(context: Context, currency: FiatCurrency) = context.userPrefs.edit { it[FIAT_CURRENCY] = currency.name } private val SHOW_AMOUNT_IN_FIAT = booleanPreferencesKey("SHOW_AMOUNT_IN_FIAT") 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 4c8c0844b..70eb6b6f3 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 @@ -65,182 +65,25 @@ fun BitcoinUnit.label(): String = when (this) { @Composable fun FiatCurrency.labels(): Pair { - val code = this.name val context = LocalContext.current - return remember(key1 = code) { + return remember(key1 = displayCode) { val fullName = when { - code.length == 3 -> try { - Currency.getInstance(code).displayName + // use the free market rates as default. Name for official rates gets a special tag, as those rates are usually inaccurate. + this == FiatCurrency.ARS -> context.getString(R.string.currency_ars_official) + this == FiatCurrency.ARS_BM -> context.getString(R.string.currency_ars_bm) + this == FiatCurrency.CUP -> context.getString(R.string.currency_cup_official) + this == FiatCurrency.CUP_FM -> context.getString(R.string.currency_cup_fm) + this == FiatCurrency.LBP -> context.getString(R.string.currency_lbp_official) + this == FiatCurrency.LBP_BM -> context.getString(R.string.currency_lbp_bm) + // use the JVM API otherwise to get the name + displayCode.length == 3 -> try { + Currency.getInstance(displayCode).displayName } catch (e: Exception) { "N/A" } - code == "ARS_BM" -> context.getString(R.string.currency_ars_bm) - code == "CUP_FM" -> context.getString(R.string.currency_cup_fm) - code == "LBP_BM" -> context.getString(R.string.currency_lbp_bm) else -> "N/A" } - val flag = getFlag(code) - "$flag $code" to fullName - } -} - -private fun getFlag(code: String): String { - return when (code) { - "AED" -> "๐Ÿ‡ฆ๐Ÿ‡ช" // United Arab Emirates Dirham - "AFN" -> "๐Ÿ‡ฆ๐Ÿ‡ซ" // Afghan Afghani - "ALL" -> "๐Ÿ‡ฆ๐Ÿ‡ฑ" // Albanian Lek - "AMD" -> "๐Ÿ‡ฆ๐Ÿ‡ฒ" // Armenian Dram - "ANG" -> "๐Ÿ‡ณ๐Ÿ‡ฑ" // Netherlands Antillean Guilder - "AOA" -> "๐Ÿ‡ฆ๐Ÿ‡ด" // Angolan Kwanza - "ARS_BM" -> "๐Ÿ‡ฆ๐Ÿ‡ท" // Argentine Peso (blue market) - "ARS" -> "๐Ÿ‡ฆ๐Ÿ‡ท" // Argentine Peso - "AUD" -> "๐Ÿ‡ฆ๐Ÿ‡บ" // Australian Dollar - "AWG" -> "๐Ÿ‡ฆ๐Ÿ‡ผ" // Aruban Florin - "AZN" -> "๐Ÿ‡ฆ๐Ÿ‡ฟ" // Azerbaijani Manat - "BAM" -> "๐Ÿ‡ง๐Ÿ‡ฆ" // Bosnia-Herzegovina Convertible Mark - "BBD" -> "๐Ÿ‡ง๐Ÿ‡ง" // Barbadian Dollar - "BDT" -> "๐Ÿ‡ง๐Ÿ‡ฉ" // Bangladeshi Taka - "BGN" -> "๐Ÿ‡ง๐Ÿ‡ฌ" // Bulgarian Lev - "BHD" -> "๐Ÿ‡ง๐Ÿ‡ญ" // Bahraini Dinar - "BIF" -> "๐Ÿ‡ง๐Ÿ‡ฎ" // Burundian Franc - "BMD" -> "๐Ÿ‡ง๐Ÿ‡ฒ" // Bermudan Dollar - "BND" -> "๐Ÿ‡ง๐Ÿ‡ณ" // Brunei Dollar - "BOB" -> "๐Ÿ‡ง๐Ÿ‡ด" // Bolivian Boliviano - "BRL" -> "๐Ÿ‡ง๐Ÿ‡ท" // Brazilian Real - "BSD" -> "๐Ÿ‡ง๐Ÿ‡ธ" // Bahamian Dollar - "BTN" -> "๐Ÿ‡ง๐Ÿ‡น" // Bhutanese Ngultrum - "BWP" -> "๐Ÿ‡ง๐Ÿ‡ผ" // Botswanan Pula - "BZD" -> "๐Ÿ‡ง๐Ÿ‡ฟ" // Belize Dollar - "CAD" -> "๐Ÿ‡จ๐Ÿ‡ฆ" // Canadian Dollar - "CDF" -> "๐Ÿ‡จ๐Ÿ‡ฉ" // Congolese Franc - "CHF" -> "๐Ÿ‡จ๐Ÿ‡ญ" // Swiss Franc - "CLP" -> "๐Ÿ‡จ๐Ÿ‡ฑ" // Chilean Peso - "CNH" -> "๐Ÿ‡จ๐Ÿ‡ณ" // Chinese Yuan (offshore) - "CNY" -> "๐Ÿ‡จ๐Ÿ‡ณ" // Chinese Yuan (onshore) - "COP" -> "๐Ÿ‡จ๐Ÿ‡ด" // Colombian Peso - "CRC" -> "๐Ÿ‡จ๐Ÿ‡ท" // Costa Rican Colรณn - "CUP" -> "๐Ÿ‡จ๐Ÿ‡บ" // Cuban Peso - "CUP_FM" -> "๐Ÿ‡จ๐Ÿ‡บ" // Cuban Peso (free market) - "CVE" -> "๐Ÿ‡จ๐Ÿ‡ป" // Cape Verdean Escudo - "CZK" -> "๐Ÿ‡จ๐Ÿ‡ฟ" // Czech Koruna - "DJF" -> "๐Ÿ‡ฉ๐Ÿ‡ฏ" // Djiboutian Franc - "DKK" -> "๐Ÿ‡ฉ๐Ÿ‡ฐ" // Danish Krone - "DOP" -> "๐Ÿ‡ฉ๐Ÿ‡ด" // Dominican Peso - "DZD" -> "๐Ÿ‡ฉ๐Ÿ‡ฟ" // Algerian Dinar - "EGP" -> "๐Ÿ‡ช๐Ÿ‡ฌ" // Egyptian Pound - "ERN" -> "๐Ÿ‡ช๐Ÿ‡ท" // Eritrean Nakfa - "ETB" -> "๐Ÿ‡ช๐Ÿ‡น" // Ethiopian Birr - "EUR" -> "๐Ÿ‡ช๐Ÿ‡บ" // Euro - "FJD" -> "๐Ÿ‡ซ๐Ÿ‡ฏ" // Fijian Dollar - "FKP" -> "๐Ÿ‡ซ๐Ÿ‡ฐ" // Falkland Islands Pound - "GBP" -> "๐Ÿ‡ฌ๐Ÿ‡ง" // British Pound Sterling - "GEL" -> "๐Ÿ‡ฌ๐Ÿ‡ช" // Georgian Lari - "GHS" -> "๐Ÿ‡ฌ๐Ÿ‡ญ" // Ghanaian Cedi - "GIP" -> "๐Ÿ‡ฌ๐Ÿ‡ฎ" // Gibraltar Pound - "GMD" -> "๐Ÿ‡ฌ๐Ÿ‡ฒ" // Gambian Dalasi - "GNF" -> "๐Ÿ‡ฌ๐Ÿ‡ณ" // Guinean Franc - "GTQ" -> "๐Ÿ‡ฌ๐Ÿ‡น" // Guatemalan Quetzal - "GYD" -> "๐Ÿ‡ฌ๐Ÿ‡พ" // Guyanaese Dollar - "HKD" -> "๐Ÿ‡ญ๐Ÿ‡ฐ" // Hong Kong Dollar - "HNL" -> "๐Ÿ‡ญ๐Ÿ‡ณ" // Honduran Lempira - "HRK" -> "๐Ÿ‡ญ๐Ÿ‡ท" // Croatian Kuna - "HTG" -> "๐Ÿ‡ญ๐Ÿ‡น" // Haitian Gourde - "HUF" -> "๐Ÿ‡ญ๐Ÿ‡บ" // Hungarian Forint - "IDR" -> "๐Ÿ‡ฎ๐Ÿ‡ฉ" // Indonesian Rupiah - "ILS" -> "๐Ÿ‡ฎ๐Ÿ‡ฑ" // Israeli New Sheqel - "INR" -> "๐Ÿ‡ฎ๐Ÿ‡ณ" // Indian Rupee - "IQD" -> "๐Ÿ‡ฎ๐Ÿ‡ถ" // Iraqi Dinar - "IRR" -> "๐Ÿ‡ฎ๐Ÿ‡ท" // Iranian Rial - "ISK" -> "๐Ÿ‡ฎ๐Ÿ‡ธ" // Icelandic Krรณna - "JEP" -> "๐Ÿ‡ฏ๐Ÿ‡ช" // Jersey Pound - "JMD" -> "๐Ÿ‡ฏ๐Ÿ‡ฒ" // Jamaican Dollar - "JOD" -> "๐Ÿ‡ฏ๐Ÿ‡ด" // Jordanian Dinar - "JPY" -> "๐Ÿ‡ฏ๐Ÿ‡ต" // Japanese Yen - "KES" -> "๐Ÿ‡ฐ๐Ÿ‡ช" // Kenyan Shilling - "KGS" -> "๐Ÿ‡ฐ๐Ÿ‡ฌ" // Kyrgystani Som - "KHR" -> "๐Ÿ‡ฐ๐Ÿ‡ญ" // Cambodian Riel - "KMF" -> "๐Ÿ‡ฐ๐Ÿ‡ฒ" // Comorian Franc - "KPW" -> "๐Ÿ‡ฐ๐Ÿ‡ต" // North Korean Won - "KRW" -> "๐Ÿ‡ฐ๐Ÿ‡ท" // South Korean Won - "KWD" -> "๐Ÿ‡ฐ๐Ÿ‡ผ" // Kuwaiti Dinar - "KYD" -> "๐Ÿ‡ฐ๐Ÿ‡พ" // Cayman Islands Dollar - "KZT" -> "๐Ÿ‡ฐ๐Ÿ‡ฟ" // Kazakhstani Tenge - "LAK" -> "๐Ÿ‡ฑ๐Ÿ‡ฆ" // Laotian Kip - "LBP" -> "๐Ÿ‡ฑ๐Ÿ‡ง" // Lebanese Pound - "LBP_BM" -> "๐Ÿ‡ฑ๐Ÿ‡ง" // Lebanese Pound - "LKR" -> "๐Ÿ‡ฑ๐Ÿ‡ฐ" // Sri Lankan Rupee - "LRD" -> "๐Ÿ‡ฑ๐Ÿ‡ท" // Liberian Dollar - "LSL" -> "๐Ÿ‡ฑ๐Ÿ‡ธ" // Lesotho Loti - "LYD" -> "๐Ÿ‡ฑ๐Ÿ‡พ" // Libyan Dinar - "MAD" -> "๐Ÿ‡ฒ๐Ÿ‡ฆ" // Moroccan Dirham - "MDL" -> "๐Ÿ‡ฒ๐Ÿ‡ฉ" // Moldovan Leu - "MGA" -> "๐Ÿ‡ฒ๐Ÿ‡ฌ" // Malagasy Ariary - "MKD" -> "๐Ÿ‡ฒ๐Ÿ‡ฐ" // Macedonian Denar - "MMK" -> "๐Ÿ‡ฒ๐Ÿ‡ฒ" // Myanmar Kyat - "MNT" -> "๐Ÿ‡ฒ๐Ÿ‡ณ" // Mongolian Tugrik - "MOP" -> "๐Ÿ‡ฒ๐Ÿ‡ด" // Macanese Pataca - "MUR" -> "๐Ÿ‡ฒ๐Ÿ‡บ" // Mauritian Rupee - "MVR" -> "๐Ÿ‡ฒ๐Ÿ‡ป" // Maldivian Rufiyaa - "MWK" -> "๐Ÿ‡ฒ๐Ÿ‡ผ" // Malawian Kwacha - "MXN" -> "๐Ÿ‡ฒ๐Ÿ‡ฝ" // Mexican Peso - "MYR" -> "๐Ÿ‡ฒ๐Ÿ‡พ" // Malaysian Ringgit - "MZN" -> "๐Ÿ‡ฒ๐Ÿ‡ฟ" // Mozambican Metical - "NAD" -> "๐Ÿ‡ณ๐Ÿ‡ฆ" // Namibian Dollar - "NGN" -> "๐Ÿ‡ณ๐Ÿ‡ฌ" // Nigerian Naira - "NIO" -> "๐Ÿ‡ณ๐Ÿ‡ฎ" // Nicaraguan Cรณrdoba - "NOK" -> "๐Ÿ‡ณ๐Ÿ‡ด" // Norwegian Krone - "NPR" -> "๐Ÿ‡ณ๐Ÿ‡ต" // Nepalese Rupee - "NZD" -> "๐Ÿ‡ณ๐Ÿ‡ฟ" // New Zealand Dollar - "OMR" -> "๐Ÿ‡ด๐Ÿ‡ฒ" // Omani Rial - "PAB" -> "๐Ÿ‡ต๐Ÿ‡ฆ" // Panamanian Balboa - "PEN" -> "๐Ÿ‡ต๐Ÿ‡ช" // Peruvian Nuevo Sol - "PGK" -> "๐Ÿ‡ต๐Ÿ‡ฌ" // Papua New Guinean Kina - "PHP" -> "๐Ÿ‡ต๐Ÿ‡ญ" // Philippine Peso - "PKR" -> "๐Ÿ‡ต๐Ÿ‡ฐ" // Pakistani Rupee - "PLN" -> "๐Ÿ‡ต๐Ÿ‡ฑ" // Polish Zloty - "PYG" -> "๐Ÿ‡ต๐Ÿ‡พ" // Paraguayan Guarani - "QAR" -> "๐Ÿ‡ถ๐Ÿ‡ฆ" // Qatari Rial - "RON" -> "๐Ÿ‡ท๐Ÿ‡ด" // Romanian Leu - "RSD" -> "๐Ÿ‡ท๐Ÿ‡ธ" // Serbian Dinar - "RUB" -> "๐Ÿ‡ท๐Ÿ‡บ" // Russian Ruble - "RWF" -> "๐Ÿ‡ท๐Ÿ‡ผ" // Rwandan Franc - "SAR" -> "๐Ÿ‡ธ๐Ÿ‡ฆ" // Saudi Riyal - "SBD" -> "๐Ÿ‡ธ๐Ÿ‡ง" // Solomon Islands Dollar - "SCR" -> "๐Ÿ‡ธ๐Ÿ‡จ" // Seychellois Rupee - "SDG" -> "๐Ÿ‡ธ๐Ÿ‡ฉ" // Sudanese Pound - "SEK" -> "๐Ÿ‡ธ๐Ÿ‡ช" // Swedish Krona - "SGD" -> "๐Ÿ‡ธ๐Ÿ‡ฌ" // Singapore Dollar - "SHP" -> "๐Ÿ‡ธ๐Ÿ‡ญ" // Saint Helena Pound - "SLL" -> "๐Ÿ‡ธ๐Ÿ‡ฑ" // Sierra Leonean Leone - "SOS" -> "๐Ÿ‡ธ๐Ÿ‡ด" // Somali Shilling - "SRD" -> "๐Ÿ‡ธ๐Ÿ‡ท" // Surinamese Dollar - "SYP" -> "๐Ÿ‡ธ๐Ÿ‡พ" // Syrian Pound - "SZL" -> "๐Ÿ‡ธ๐Ÿ‡ฟ" // Swazi Lilangeni - "THB" -> "๐Ÿ‡น๐Ÿ‡ญ" // Thai Baht - "TJS" -> "๐Ÿ‡น๐Ÿ‡ฏ" // Tajikistani Somoni - "TMT" -> "๐Ÿ‡น๐Ÿ‡ฒ" // Turkmenistani Manat - "TND" -> "๐Ÿ‡น๐Ÿ‡ณ" // Tunisian Dinar - "TOP" -> "๐Ÿ‡น๐Ÿ‡ด" // Tongan Paสปanga - "TRY" -> "๐Ÿ‡น๐Ÿ‡ท" // Turkish Lira - "TTD" -> "๐Ÿ‡น๐Ÿ‡น" // Trinidad and Tobago Dollar - "TWD" -> "๐Ÿ‡น๐Ÿ‡ผ" // New Taiwan Dollar - "TZS" -> "๐Ÿ‡น๐Ÿ‡ฟ" // Tanzanian Shilling - "UAH" -> "๐Ÿ‡บ๐Ÿ‡ฆ" // Ukrainian Hryvnia - "UGX" -> "๐Ÿ‡บ๐Ÿ‡ฌ" // Ugandan Shilling - "USD" -> "๐Ÿ‡บ๐Ÿ‡ธ" // United States Dollar - "UYU" -> "๐Ÿ‡บ๐Ÿ‡พ" // Uruguayan Peso - "UZS" -> "๐Ÿ‡บ๐Ÿ‡ฟ" // Uzbekistan Som - "VND" -> "๐Ÿ‡ป๐Ÿ‡ณ" // Vietnamese Dong - "VUV" -> "๐Ÿ‡ป๐Ÿ‡บ" // Vanuatu Vatu - "WST" -> "๐Ÿ‡ผ๐Ÿ‡ธ" // Samoan Tala - "XAF" -> "๐Ÿ‡จ๐Ÿ‡ฒ" // CFA Franc BEAC - multiple options, chose country with highest GDP - "XCD" -> "๐Ÿ‡ฑ๐Ÿ‡จ" // East Caribbean Dollar - multiple options, chose country with highest GDP - "XOF" -> "๐Ÿ‡จ๐Ÿ‡ฎ" // CFA Franc BCEAO - multiple options, chose country with highest GDP - "XPF" -> "๐Ÿ‡ณ๐Ÿ‡จ" // CFP Franc - multiple options, chose country with highest GDP - "YER" -> "๐Ÿ‡พ๐Ÿ‡ช" // Yemeni Rial - "ZAR" -> "๐Ÿ‡ฟ๐Ÿ‡ฆ" // South African Rand - "ZMW" -> "๐Ÿ‡ฟ๐Ÿ‡ฒ" // Zambian Kwacha - else -> "๐Ÿณ๏ธ" + "$flag $displayCode" to fullName } } diff --git a/phoenix-android/src/main/res/values/strings.xml b/phoenix-android/src/main/res/values/strings.xml index 33d0a022b..245431550 100644 --- a/phoenix-android/src/main/res/values/strings.xml +++ b/phoenix-android/src/main/res/values/strings.xml @@ -122,7 +122,7 @@ Cannot pay more than %1$s. This amount is below the requested amount of %1$s. Send to - Payment description + Description Fee N/A Loading feeโ€ฆ @@ -512,7 +512,7 @@ Served by - Payment description + Description Attach a message My message You can attach a message to the payment. This message will be sent to the recipient. @@ -654,9 +654,12 @@ - Argentine Peso (blue market) - Cuban Peso (free market) - Lebanese Pound (black market) + Argentine Peso (official rate) + Argentine Peso + Cuban Peso (official rate) + Cuban Peso + Lebanese Pound (official rate) + Lebanese Pound 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 1fffd9352..84a30253d 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 @@ -66,7 +66,7 @@ class LnurlAuthTest { val legacyKeyManager = fr.acinq.eclair.crypto.LocalKeyManager(seed, Block.LivenetGenesisBlock().hash()) val kmpKeyManager = LocalKeyManager( seed = seed.toArray().byteVector64(), - chain = NodeParams.Chain.Mainnet, + chain = NodeParams.Chain.Testnet, remoteSwapInExtendedPublicKey = "tpubDDt5vQap1awkyDXx1z1cP7QFKSZHDCCpbU8nSq9jy7X2grTjUVZDePexf6gc6AHtRRzkgfPW87K6EKUVV6t3Hu2hg7YkHkmMeLSfrP85x41" ) diff --git a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj index 9e22b5ef2..2c7f13e7a 100644 --- a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj +++ b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj @@ -63,7 +63,6 @@ DC1B325029FC3D5900F7F45F /* OnChainDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1B324F29FC3D5900F7F45F /* OnChainDetails.swift */; }; DC1D2B4B2593EB860036AD38 /* Currency.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1D2B4A2593EB850036AD38 /* Currency.swift */; }; DC1D2B502594CE900036AD38 /* FormattedAmount.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1D2B4F2594CE900036AD38 /* FormattedAmount.swift */; }; - DC27E4C22791C00F00C777CC /* PrivacyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC27E4C12791C00F00C777CC /* PrivacyView.swift */; }; DC27E4C42791C58C00C777CC /* PaymentsBackupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC27E4C32791C58C00C777CC /* PaymentsBackupView.swift */; }; DC27E4CB2791D17A00C777CC /* RecoveryPhraseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC27E4C92791D17A00C777CC /* RecoveryPhraseView.swift */; }; DC27E4CC2791D17A00C777CC /* CloudBackupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC27E4CA2791D17A00C777CC /* CloudBackupView.swift */; }; @@ -83,6 +82,7 @@ DC2F432227B69FAA0006FCC4 /* SwapInDisabledPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC2F432127B69FAA0006FCC4 /* SwapInDisabledPopover.swift */; }; DC32FB3529A3D3FE009912AC /* XpcManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC32FB3429A3D3FE009912AC /* XpcManager.swift */; }; DC33369826BAF721000E3F49 /* ShortSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC33369726BAF721000E3F49 /* ShortSheet.swift */; }; + DC33C5632A7C15D40053D785 /* MainView_BigPrimary.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC33C5622A7C15D40053D785 /* MainView_BigPrimary.swift */; }; DC34399F276CEFB600CAA73A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7555FF84242A565B00829871 /* Assets.xcassets */; }; DC355E1D2A4398A8008E8A8E /* NoticeBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC355E1C2A4398A8008E8A8E /* NoticeBox.swift */; }; DC355E1F2A44A235008E8A8E /* NotificationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC355E1E2A44A235008E8A8E /* NotificationCell.swift */; }; @@ -100,10 +100,12 @@ DC422F3329392ABD00E72253 /* Date+Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC422F3229392ABD00E72253 /* Date+Format.swift */; }; DC422F3529392B0500E72253 /* Int+ToDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC422F3429392B0500E72253 /* Int+ToDate.swift */; }; DC422F3629392C0000E72253 /* Int+ToDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC422F3429392B0500E72253 /* Int+ToDate.swift */; }; + DC43096E2A7953F400E28995 /* FinalWalletDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC43096D2A7953F400E28995 /* FinalWalletDetails.swift */; }; + DC4309702A795F9900E28995 /* UtxoWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC43096F2A795F9900E28995 /* UtxoWrapper.swift */; }; DC46BAE426C6FDD300E760A6 /* CircularCheckmarkProgress in Frameworks */ = {isa = PBXBuildFile; productRef = DC46BAE326C6FDD300E760A6 /* CircularCheckmarkProgress */; }; DC46BAF326CACCF700E760A6 /* KotlinExtensions+Other.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC46BAED26CACCF700E760A6 /* KotlinExtensions+Other.swift */; }; DC46BAF426CACCF700E760A6 /* KotlinFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC46BAEE26CACCF700E760A6 /* KotlinFlow.swift */; }; - DC46BAF526CACCF700E760A6 /* KotlinPublishers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC46BAEF26CACCF700E760A6 /* KotlinPublishers.swift */; }; + DC46BAF526CACCF700E760A6 /* KotlinPublishers+Phoenix.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC46BAEF26CACCF700E760A6 /* KotlinPublishers+Phoenix.swift */; }; DC46BAF626CACCF700E760A6 /* KotlinFutures.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC46BAF026CACCF700E760A6 /* KotlinFutures.swift */; }; DC46BAF726CACCF700E760A6 /* KotlinExtensions+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC46BAF126CACCF700E760A6 /* KotlinExtensions+Conversion.swift */; }; DC46BAF826CACCF700E760A6 /* KotlinAssociatedObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC46BAF226CACCF700E760A6 /* KotlinAssociatedObject.swift */; }; @@ -141,6 +143,7 @@ DC641C802821767D00862DCD /* Prefs+BackupTransactions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC641C7F2821767D00862DCD /* Prefs+BackupTransactions.swift */; }; DC641C82282188E700862DCD /* Utils+CurrencyPrefs.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC641C81282188E700862DCD /* Utils+CurrencyPrefs.swift */; }; DC641C83282189AC00862DCD /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC18C41C256FF91100A2D083 /* Utils.swift */; }; + DC65BBF22A58A40700EBA651 /* CpfpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC65BBF12A58A40700EBA651 /* CpfpView.swift */; }; DC65D86428E2F7D700686355 /* ResetWalletView_Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC65D86328E2F7D700686355 /* ResetWalletView_Action.swift */; }; DC67654E25655D93004D4263 /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DC67654D25655D93004D4263 /* Colors.xcassets */; }; DC67E40B27F3798600496C04 /* AnimatedMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC67E40A27F3798600496C04 /* AnimatedMenu.swift */; }; @@ -157,7 +160,6 @@ DC74174D270F455D00F7E3E3 /* AES256.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC74174C270F455D00F7E3E3 /* AES256.swift */; }; DC81B79F25BF2AA200F5A52C /* MVI.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC81B79E25BF2AA200F5A52C /* MVI.swift */; }; DC82EED629789853007A5853 /* TxHistoryExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC82EED529789853007A5853 /* TxHistoryExporter.swift */; }; - DC87806F292D69C90061715B /* IncomingDepositPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC87806E292D69C90061715B /* IncomingDepositPopover.swift */; }; DC89857F25914747007B253F /* UIApplicationState+Phoenix.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89857E25914747007B253F /* UIApplicationState+Phoenix.swift */; }; DC9473FA261270B4008D7242 /* MVI+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9473F9261270B4008D7242 /* MVI+Mock.swift */; }; DC9545C429490321008FCEF4 /* NotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9545C329490321008FCEF4 /* NotificationContent.swift */; }; @@ -169,7 +171,8 @@ DC9B8EE225D72CC200E13818 /* ForceCloseChannelsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9B8EE125D72CC200E13818 /* ForceCloseChannelsView.swift */; }; DC9E7EC32A12955300A5F1D0 /* LiquidityHTML.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9E7EC22A12955300A5F1D0 /* LiquidityHTML.swift */; }; DC9E7EC62A1295B100A5F1D0 /* liquidity.html in Resources */ = {isa = PBXBuildFile; fileRef = DC9E7EC82A1295B100A5F1D0 /* liquidity.html */; }; - DCA125752A27EDDB00DA2F7F /* MempoolSpace.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA125742A27EDDB00DA2F7F /* MempoolSpace.swift */; }; + DCA125752A27EDDB00DA2F7F /* MempoolRecommendedResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA125742A27EDDB00DA2F7F /* MempoolRecommendedResponse.swift */; }; + DCA3B41F2A5471C900E6B231 /* MinerFeeInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA3B41E2A5471C900E6B231 /* MinerFeeInfo.swift */; }; DCA5391629F047C6001BD3D5 /* ComingSoonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA5391529F047C6001BD3D5 /* ComingSoonView.swift */; }; DCA5391A29F1DDE7001BD3D5 /* SegmentedPicker in Frameworks */ = {isa = PBXBuildFile; productRef = DCA5391929F1DDE7001BD3D5 /* SegmentedPicker */; }; DCA5391C29F7202F001BD3D5 /* ChannelInfoPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA5391B29F7202F001BD3D5 /* ChannelInfoPopup.swift */; }; @@ -215,6 +218,8 @@ DCB511CA281AED58001BC525 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB511C9281AED58001BC525 /* NotificationService.swift */; }; DCB511CE281AED58001BC525 /* phoenix-notifySrvExt.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DCB511C7281AED58001BC525 /* phoenix-notifySrvExt.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DCB5D2DF280879460020B8F5 /* DeviceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB5D2DE280879460020B8F5 /* DeviceInfo.swift */; }; + DCB62F472A5DF19D00912A71 /* KotlinPublishers+Lightning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB62F462A5DF19D00912A71 /* KotlinPublishers+Lightning.swift */; }; + DCB62F492A5E09F900912A71 /* SpliceOutProblem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB62F482A5E09F900912A71 /* SpliceOutProblem.swift */; }; DCB876302735AA7300657570 /* UserDefaults+Serialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB8762F2735AA7300657570 /* UserDefaults+Serialization.swift */; }; DCB876322735AAB500657570 /* UserDefaults+Codable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB876312735AAB500657570 /* UserDefaults+Codable.swift */; }; DCBA371B2758076F00610EC8 /* SyncSeedManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA371A2758076F00610EC8 /* SyncSeedManager.swift */; }; @@ -241,6 +246,7 @@ DCDD9ED428637EBB001800A3 /* ToolsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCDD9ED328637EBB001800A3 /* ToolsButton.swift */; }; DCDD9ED628637FD7001800A3 /* AppStatusButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCDD9ED528637FD7001800A3 /* AppStatusButton.swift */; }; DCE1E5FA26418183005465B8 /* Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE1E5F926418183005465B8 /* Toast.swift */; }; + DCE3C7AB2A6AD3CC00F4D385 /* MempoolMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE3C7AA2A6AD3CC00F4D385 /* MempoolMonitor.swift */; }; DCE6FB8C28D0B5F200054511 /* ResetWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE6FB8B28D0B5F200054511 /* ResetWalletView.swift */; }; DCE7232E27AD68CD0017CF56 /* SyncTxManager_Actor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE7232D27AD68CD0017CF56 /* SyncTxManager_Actor.swift */; }; DCE7233027B167240017CF56 /* SyncSeedManager_Actor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE7232F27B167240017CF56 /* SyncSeedManager_Actor.swift */; }; @@ -249,7 +255,7 @@ DCE9F95728412A9D005E03ED /* PaymentSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE9F95628412A9D005E03ED /* PaymentSummaryView.swift */; }; DCEAE5B72943CC7400320C46 /* RangeSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCEAE5B62943CC7400320C46 /* RangeSheet.swift */; }; DCEAE5B92943D64B00320C46 /* MsatRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCEAE5B82943D64B00320C46 /* MsatRange.swift */; }; - DCEB2795282D7A9F0096B87E /* KotlinPublishers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC46BAEF26CACCF700E760A6 /* KotlinPublishers.swift */; }; + DCEB2795282D7A9F0096B87E /* KotlinPublishers+Phoenix.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC46BAEF26CACCF700E760A6 /* KotlinPublishers+Phoenix.swift */; }; DCEB2796282D7AAB0096B87E /* KotlinTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC74174A270F332700F7E3E3 /* KotlinTypes.swift */; }; DCEB2797282D7ADC0096B87E /* KotlinFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC46BAEE26CACCF700E760A6 /* KotlinFlow.swift */; }; DCEB2798282D7B070096B87E /* KotlinExtensions+Other.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC46BAED26CACCF700E760A6 /* KotlinExtensions+Other.swift */; }; @@ -262,6 +268,7 @@ DCF9CFD52862656E001AD33F /* Asserts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFC72032862237400D6B293 /* Asserts.swift */; }; DCFA8759260E6F2E00AE8953 /* IntroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFA8758260E6F2E00AE8953 /* IntroView.swift */; }; DCFA876D260E91E600AE8953 /* IntroContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFA876C260E91E600AE8953 /* IntroContainer.swift */; }; + DCFAEFC92A72F48700330088 /* SwapInWalletDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFAEFC82A72F48700330088 /* SwapInWalletDetails.swift */; }; DCFC72042862237400D6B293 /* Asserts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFC72032862237400D6B293 /* Asserts.swift */; }; DCFD079126D84A380020DD8E /* HorizontalActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFD079026D84A380020DD8E /* HorizontalActivity.swift */; }; F4AED298257A50CD009485C1 /* LogsConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4AED296257A50CD009485C1 /* LogsConfigurationView.swift */; }; @@ -399,7 +406,6 @@ DC1B324F29FC3D5900F7F45F /* OnChainDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnChainDetails.swift; sourceTree = ""; }; DC1D2B4A2593EB850036AD38 /* Currency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Currency.swift; sourceTree = ""; }; DC1D2B4F2594CE900036AD38 /* FormattedAmount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattedAmount.swift; sourceTree = ""; }; - DC27E4C12791C00F00C777CC /* PrivacyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyView.swift; sourceTree = ""; }; DC27E4C32791C58C00C777CC /* PaymentsBackupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentsBackupView.swift; sourceTree = ""; }; DC27E4C92791D17A00C777CC /* RecoveryPhraseView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecoveryPhraseView.swift; sourceTree = ""; }; DC27E4CA2791D17A00C777CC /* CloudBackupView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudBackupView.swift; sourceTree = ""; }; @@ -419,6 +425,7 @@ DC2F432127B69FAA0006FCC4 /* SwapInDisabledPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapInDisabledPopover.swift; sourceTree = ""; }; DC32FB3429A3D3FE009912AC /* XpcManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XpcManager.swift; sourceTree = ""; }; DC33369726BAF721000E3F49 /* ShortSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortSheet.swift; sourceTree = ""; }; + DC33C5622A7C15D40053D785 /* MainView_BigPrimary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView_BigPrimary.swift; sourceTree = ""; }; DC355E1C2A4398A8008E8A8E /* NoticeBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeBox.swift; sourceTree = ""; }; DC355E1E2A44A235008E8A8E /* NotificationCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCell.swift; sourceTree = ""; }; DC355E202A44D838008E8A8E /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = ""; }; @@ -434,9 +441,11 @@ DC39D4F02874DDF40030F18D /* View+If.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+If.swift"; sourceTree = ""; }; DC422F3229392ABD00E72253 /* Date+Format.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Date+Format.swift"; sourceTree = ""; }; DC422F3429392B0500E72253 /* Int+ToDate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Int+ToDate.swift"; sourceTree = ""; }; + DC43096D2A7953F400E28995 /* FinalWalletDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinalWalletDetails.swift; sourceTree = ""; }; + DC43096F2A795F9900E28995 /* UtxoWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UtxoWrapper.swift; sourceTree = ""; }; DC46BAED26CACCF700E760A6 /* KotlinExtensions+Other.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "KotlinExtensions+Other.swift"; sourceTree = ""; }; DC46BAEE26CACCF700E760A6 /* KotlinFlow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KotlinFlow.swift; sourceTree = ""; }; - DC46BAEF26CACCF700E760A6 /* KotlinPublishers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KotlinPublishers.swift; sourceTree = ""; }; + DC46BAEF26CACCF700E760A6 /* KotlinPublishers+Phoenix.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "KotlinPublishers+Phoenix.swift"; sourceTree = ""; }; DC46BAF026CACCF700E760A6 /* KotlinFutures.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KotlinFutures.swift; sourceTree = ""; }; DC46BAF126CACCF700E760A6 /* KotlinExtensions+Conversion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "KotlinExtensions+Conversion.swift"; sourceTree = ""; }; DC46BAF226CACCF700E760A6 /* KotlinAssociatedObject.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KotlinAssociatedObject.swift; sourceTree = ""; }; @@ -465,6 +474,7 @@ DC641C7D2821744000862DCD /* Currency+CurrencyPrefs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Currency+CurrencyPrefs.swift"; sourceTree = ""; }; DC641C7F2821767D00862DCD /* Prefs+BackupTransactions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Prefs+BackupTransactions.swift"; sourceTree = ""; }; DC641C81282188E700862DCD /* Utils+CurrencyPrefs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Utils+CurrencyPrefs.swift"; sourceTree = ""; }; + DC65BBF12A58A40700EBA651 /* CpfpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CpfpView.swift; sourceTree = ""; }; DC65D86328E2F7D700686355 /* ResetWalletView_Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetWalletView_Action.swift; sourceTree = ""; }; DC67654D25655D93004D4263 /* Colors.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Colors.xcassets; sourceTree = ""; }; DC67E40A27F3798600496C04 /* AnimatedMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedMenu.swift; sourceTree = ""; }; @@ -482,7 +492,6 @@ DC74174C270F455D00F7E3E3 /* AES256.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AES256.swift; sourceTree = ""; }; DC81B79E25BF2AA200F5A52C /* MVI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MVI.swift; sourceTree = ""; }; DC82EED529789853007A5853 /* TxHistoryExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TxHistoryExporter.swift; sourceTree = ""; }; - DC87806E292D69C90061715B /* IncomingDepositPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingDepositPopover.swift; sourceTree = ""; }; DC89857E25914747007B253F /* UIApplicationState+Phoenix.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplicationState+Phoenix.swift"; sourceTree = ""; }; DC9473F9261270B4008D7242 /* MVI+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MVI+Mock.swift"; sourceTree = ""; }; DC9545C329490321008FCEF4 /* NotificationContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContent.swift; sourceTree = ""; }; @@ -493,7 +502,8 @@ DC9B8EE125D72CC200E13818 /* ForceCloseChannelsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForceCloseChannelsView.swift; sourceTree = ""; }; DC9E7EC22A12955300A5F1D0 /* LiquidityHTML.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidityHTML.swift; sourceTree = ""; }; DC9E7EC72A1295B100A5F1D0 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = Base; path = Base.lproj/liquidity.html; sourceTree = ""; }; - DCA125742A27EDDB00DA2F7F /* MempoolSpace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MempoolSpace.swift; sourceTree = ""; }; + DCA125742A27EDDB00DA2F7F /* MempoolRecommendedResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MempoolRecommendedResponse.swift; sourceTree = ""; }; + DCA3B41E2A5471C900E6B231 /* MinerFeeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MinerFeeInfo.swift; sourceTree = ""; }; DCA5391529F047C6001BD3D5 /* ComingSoonView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ComingSoonView.swift; sourceTree = ""; }; DCA5391B29F7202F001BD3D5 /* ChannelInfoPopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelInfoPopup.swift; sourceTree = ""; }; DCA6DEC52829BDEB0073C658 /* CrossProcessCommunication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossProcessCommunication.swift; sourceTree = ""; }; @@ -532,6 +542,8 @@ DCB511C9281AED58001BC525 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; DCB511CB281AED58001BC525 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; DCB5D2DE280879460020B8F5 /* DeviceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfo.swift; sourceTree = ""; }; + DCB62F462A5DF19D00912A71 /* KotlinPublishers+Lightning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KotlinPublishers+Lightning.swift"; sourceTree = ""; }; + DCB62F482A5E09F900912A71 /* SpliceOutProblem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpliceOutProblem.swift; sourceTree = ""; }; DCB8762F2735AA7300657570 /* UserDefaults+Serialization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+Serialization.swift"; sourceTree = ""; }; DCB876312735AAB500657570 /* UserDefaults+Codable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+Codable.swift"; sourceTree = ""; }; DCBA371A2758076F00610EC8 /* SyncSeedManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncSeedManager.swift; sourceTree = ""; }; @@ -557,6 +569,7 @@ DCDD9ED328637EBB001800A3 /* ToolsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolsButton.swift; sourceTree = ""; }; DCDD9ED528637FD7001800A3 /* AppStatusButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStatusButton.swift; sourceTree = ""; }; DCE1E5F926418183005465B8 /* Toast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toast.swift; sourceTree = ""; }; + DCE3C7AA2A6AD3CC00F4D385 /* MempoolMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MempoolMonitor.swift; sourceTree = ""; }; DCE6FB8B28D0B5F200054511 /* ResetWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetWalletView.swift; sourceTree = ""; }; DCE7232D27AD68CD0017CF56 /* SyncTxManager_Actor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTxManager_Actor.swift; sourceTree = ""; }; DCE7232F27B167240017CF56 /* SyncSeedManager_Actor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncSeedManager_Actor.swift; sourceTree = ""; }; @@ -575,6 +588,7 @@ DCF9312F279F1BD900FD7776 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = ar; path = ar.lproj/about.html; sourceTree = ""; }; DCFA8758260E6F2E00AE8953 /* IntroView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroView.swift; sourceTree = ""; }; DCFA876C260E91E600AE8953 /* IntroContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroContainer.swift; sourceTree = ""; }; + DCFAEFC82A72F48700330088 /* SwapInWalletDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapInWalletDetails.swift; sourceTree = ""; }; DCFC72032862237400D6B293 /* Asserts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Asserts.swift; sourceTree = ""; }; DCFD079026D84A380020DD8E /* HorizontalActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HorizontalActivity.swift; sourceTree = ""; }; F4AED296257A50CD009485C1 /* LogsConfigurationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogsConfigurationView.swift; sourceTree = ""; }; @@ -631,18 +645,14 @@ children = ( DC0E31BA26EFDED4002071C6 /* VSlider.swift */, C8D7A607F036B3184C3D6EED /* QRCodeScanner.swift */, - DCE1E5F926418183005465B8 /* Toast.swift */, - DC682FE7258175CE00CA1114 /* Popover.swift */, DC72C33825A663CA008A927A /* QRCode.swift */, DC99E93F25BA141000FB20F7 /* LocalWebView.swift */, DC0994AF263A074C003031CA /* InfoGrid.swift */, - DC33369726BAF721000E3F49 /* ShortSheet.swift */, DCFD079026D84A380020DD8E /* HorizontalActivity.swift */, DCEC6A1727A82A98002C20BA /* ImagePicker.swift */, DC67E40A27F3798600496C04 /* AnimatedMenu.swift */, DC08A51727FB39530041603B /* AnimatedChevron.swift */, DC08A51927FB6C5F0041603B /* TopTab.swift */, - DC39D4EE287497440030F18D /* SmartModal.swift */, DC39D4F02874DDF40030F18D /* View+If.swift */, DC6D26E229E76557006A7814 /* AnimatedClock.swift */, DCB30E532A0AABAF00E7D7A2 /* InfoPopoverWindow.swift */, @@ -692,6 +702,7 @@ DCFA8757260E6DFF00AE8953 /* onboarding */, C8D7A7335040D755924F8FFC /* configuration */, DCAC9FC42968793D0098D769 /* compatibility */, + DCE3C7AC2A6B111A00F4D385 /* layers */, 53BEF0E7A62D4973FCC99476 /* widgets */, DC49DA8C258BB85F005BC4BC /* style */, DCB5D2DD280879160020B8F5 /* environment */, @@ -760,6 +771,7 @@ 53BEF1337AFCFF0AE82A46BD /* utils */, DCACF6EE2566D0A60009B01E /* extensions */, DCA6DEC42829BD060073C658 /* xpc */, + DCE3C7A92A6AD39E00F4D385 /* mempool */, ); path = "phoenix-ios"; sourceTree = ""; @@ -797,8 +809,9 @@ C8D7AFF1A7C09789C6CF2D06 /* ConfigurationView.swift */, DCA5391529F047C6001BD3D5 /* ComingSoonView.swift */, DCACF6E02566CEC40009B01E /* general */, - DCACF6E42566CF850009B01E /* security */, + DCACF6E42566CF850009B01E /* privacy and security */, DCACF6DF2566CEC40009B01E /* advanced */, + DCA3B41D2A53434700E6B231 /* danger zone */, ); path = configuration; sourceTree = ""; @@ -816,6 +829,7 @@ DC118BF927B44F840080BBAC /* TipSliderSheet.swift */, DC118BFD27B451890080BBAC /* MetadataSheet.swift */, DC118BFF27B4523B0080BBAC /* CommentSheet.swift */, + DCA3B41E2A5471C900E6B231 /* MinerFeeInfo.swift */, DC1844022A2690BB004D9578 /* MinerFeeSheet.swift */, DC118BF727B44E6E0080BBAC /* LoginView.swift */, DC118C0327B454720080BBAC /* PaymentInFlightView.swift */, @@ -823,23 +837,11 @@ DC118C0727B457520080BBAC /* FetchActivityNotice.swift */, DC118C0927B457F70080BBAC /* LnurlFlowErrorNotice.swift */, DCEAE5B82943D64B00320C46 /* MsatRange.swift */, - DCA125742A27EDDB00DA2F7F /* MempoolSpace.swift */, + DCB62F482A5E09F900912A71 /* SpliceOutProblem.swift */, ); path = send; sourceTree = ""; }; - DC27E4C02791BFFC00C777CC /* privacy */ = { - isa = PBXGroup; - children = ( - DC27E4C12791C00F00C777CC /* PrivacyView.swift */, - C8D7A1F8A123C59199C182C2 /* ElectrumConfigurationView.swift */, - DCE77A5727C671D600F0FA24 /* ElectrumAddressSheet.swift */, - C8D7A2BD01ADA0DE6034AE0F /* TorConfigurationView.swift */, - DC27E4C32791C58C00C777CC /* PaymentsBackupView.swift */, - ); - path = privacy; - sourceTree = ""; - }; DC27E4CD2791D18600C777CC /* recovery phrase */ = { isa = PBXGroup; children = ( @@ -890,7 +892,8 @@ DC46BAF026CACCF700E760A6 /* KotlinFutures.swift */, DC71E7342728A5720063613D /* KotlinIdentifiable.swift */, DC71E72F2723240E0063613D /* KotlinObservables.swift */, - DC46BAEF26CACCF700E760A6 /* KotlinPublishers.swift */, + DC46BAEF26CACCF700E760A6 /* KotlinPublishers+Phoenix.swift */, + DCB62F462A5DF19D00912A71 /* KotlinPublishers+Lightning.swift */, DC74174A270F332700F7E3E3 /* KotlinTypes.swift */, ); path = kotlin; @@ -1043,6 +1046,34 @@ path = notifications; sourceTree = ""; }; + DCA3B41B2A52226300E6B231 /* channel management */ = { + isa = PBXGroup; + children = ( + DCB30E582A0C3F8200E7D7A2 /* LiquidityPolicyView.swift */, + DC39A2652A12C04D00F59E39 /* LiquidityPolicyHelp.swift */, + ); + path = "channel management"; + sourceTree = ""; + }; + DCA3B41C2A533B8600E6B231 /* electrum server */ = { + isa = PBXGroup; + children = ( + C8D7A1F8A123C59199C182C2 /* ElectrumConfigurationView.swift */, + DCE77A5727C671D600F0FA24 /* ElectrumAddressSheet.swift */, + ); + path = "electrum server"; + sourceTree = ""; + }; + DCA3B41D2A53434700E6B231 /* danger zone */ = { + isa = PBXGroup; + children = ( + DC5CA4EB28F83C260048A737 /* drain wallet */, + DC591A2228D20D3800AE4D0A /* reset wallet */, + DC9B8EE125D72CC200E13818 /* ForceCloseChannelsView.swift */, + ); + path = "danger zone"; + sourceTree = ""; + }; DCA6DEC42829BD060073C658 /* xpc */ = { isa = PBXGroup; children = ( @@ -1064,10 +1095,9 @@ DCACF6DF2566CEC40009B01E /* advanced */ = { isa = PBXGroup; children = ( - DC27E4C02791BFFC00C777CC /* privacy */, - 53BEF0A8669F9379E4E4596F /* logs */, + DCFAEFC72A72F46D00330088 /* wallet */, DCFFAADC2900218B004E3C11 /* channels */, - DC591A2228D20D3800AE4D0A /* reset wallet */, + 53BEF0A8669F9379E4E4596F /* logs */, ); path = advanced; sourceTree = ""; @@ -1077,20 +1107,22 @@ children = ( C8D7A2327BC90150A3E1493D /* AboutView.swift */, DC2DC8662906ABE70079E570 /* display configuration */, - DCB30E512A0A948000E7D7A2 /* WalletInfoView.swift */, - DC27E4CD2791D18600C777CC /* recovery phrase */, DCB30E552A0C2FB400E7D7A2 /* payment options */, - DC5CA4EB28F83C260048A737 /* drain wallet */, + DCA3B41B2A52226300E6B231 /* channel management */, ); path = general; sourceTree = ""; }; - DCACF6E42566CF850009B01E /* security */ = { + DCACF6E42566CF850009B01E /* privacy and security */ = { isa = PBXGroup; children = ( DCACF7082566D0F00009B01E /* AppAccessView.swift */, + DC27E4CD2791D18600C777CC /* recovery phrase */, + DCA3B41C2A533B8600E6B231 /* electrum server */, + C8D7A2BD01ADA0DE6034AE0F /* TorConfigurationView.swift */, + DC27E4C32791C58C00C777CC /* PaymentsBackupView.swift */, ); - path = security; + path = "privacy and security"; sourceTree = ""; }; DCACF6EE2566D0A60009B01E /* extensions */ = { @@ -1140,8 +1172,6 @@ children = ( DC0732EB263CA6C3004CB88D /* PaymentOptionsView.swift */, DCB30E562A0C2FF900E7D7A2 /* MaxFeeConfiguration.swift */, - DCB30E582A0C3F8200E7D7A2 /* LiquidityPolicyView.swift */, - DC39A2652A12C04D00F59E39 /* LiquidityPolicyHelp.swift */, ); path = "payment options"; sourceTree = ""; @@ -1192,6 +1222,7 @@ DCCD046027EE045C007D57A5 /* DetailsView.swift */, DCCD045C27EE0173007D57A5 /* EditInfoView.swift */, DCCD046227EE04E1007D57A5 /* WalletPaymentExtensions.swift */, + DC65BBF12A58A40700EBA651 /* CpfpView.swift */, ); path = inspect; sourceTree = ""; @@ -1211,8 +1242,9 @@ isa = PBXGroup; children = ( DCDD9ECA28637242001800A3 /* MainView.swift */, - DCDD9ECF286377B7001800A3 /* MainView_Big.swift */, DCDD9ED1286377C5001800A3 /* MainView_Small.swift */, + DCDD9ECF286377B7001800A3 /* MainView_Big.swift */, + DC33C5622A7C15D40053D785 /* MainView_BigPrimary.swift */, 53BEFE171C182513A5762686 /* HomeView.swift */, DCDD9ED528637FD7001800A3 /* AppStatusButton.swift */, DCDD9ED328637EBB001800A3 /* ToolsButton.swift */, @@ -1221,6 +1253,26 @@ path = main; sourceTree = ""; }; + DCE3C7A92A6AD39E00F4D385 /* mempool */ = { + isa = PBXGroup; + children = ( + DCA125742A27EDDB00DA2F7F /* MempoolRecommendedResponse.swift */, + DCE3C7AA2A6AD3CC00F4D385 /* MempoolMonitor.swift */, + ); + path = mempool; + sourceTree = ""; + }; + DCE3C7AC2A6B111A00F4D385 /* layers */ = { + isa = PBXGroup; + children = ( + DCE1E5F926418183005465B8 /* Toast.swift */, + DC682FE7258175CE00CA1114 /* Popover.swift */, + DC33369726BAF721000E3F49 /* ShortSheet.swift */, + DC39D4EE287497440030F18D /* SmartModal.swift */, + ); + path = layers; + sourceTree = ""; + }; DCFA8757260E6DFF00AE8953 /* onboarding */ = { isa = PBXGroup; children = ( @@ -1233,12 +1285,22 @@ path = onboarding; sourceTree = ""; }; + DCFAEFC72A72F46D00330088 /* wallet */ = { + isa = PBXGroup; + children = ( + DCB30E512A0A948000E7D7A2 /* WalletInfoView.swift */, + DCFAEFC82A72F48700330088 /* SwapInWalletDetails.swift */, + DC43096D2A7953F400E28995 /* FinalWalletDetails.swift */, + DC43096F2A795F9900E28995 /* UtxoWrapper.swift */, + ); + path = wallet; + sourceTree = ""; + }; DCFFAADC2900218B004E3C11 /* channels */ = { isa = PBXGroup; children = ( 53BEF648B3A03C66B611BC06 /* ChannelsConfigurationView.swift */, DCA5391B29F7202F001BD3D5 /* ChannelInfoPopup.swift */, - DC9B8EE125D72CC200E13818 /* ForceCloseChannelsView.swift */, ); path = channels; sourceTree = ""; @@ -1512,6 +1574,7 @@ 7555FF7F242A565900829871 /* AppDelegate.swift in Sources */, DC74174B270F332700F7E3E3 /* KotlinTypes.swift in Sources */, DC142135261E72320075857A /* AboutHTML.swift in Sources */, + DC33C5632A7C15D40053D785 /* MainView_BigPrimary.swift in Sources */, DCEE8998288605FD00FE42DD /* PaymentCell.swift in Sources */, DCDD9ED428637EBB001800A3 /* ToolsButton.swift in Sources */, DCDD9ED0286377B7001800A3 /* MainView_Big.swift in Sources */, @@ -1519,6 +1582,7 @@ DC1706D926A71D8E00BAFCD0 /* UnlockErrorView.swift in Sources */, DC2DC8682906AC0B0079E570 /* BitcoinUnitSelector.swift in Sources */, DC118C0027B4523B0080BBAC /* CommentSheet.swift in Sources */, + DC4309702A795F9900E28995 /* UtxoWrapper.swift in Sources */, DC355E1D2A4398A8008E8A8E /* NoticeBox.swift in Sources */, 7555FF81242A565900829871 /* SceneDelegate.swift in Sources */, DCC9D99A267BD28600EA36DD /* SyncTxManager.swift in Sources */, @@ -1554,6 +1618,7 @@ DCE7233027B167240017CF56 /* SyncSeedManager_Actor.swift in Sources */, DCBA371B2758076F00610EC8 /* SyncSeedManager.swift in Sources */, DC39D4F12874DDF40030F18D /* View+If.swift in Sources */, + DCB62F492A5E09F900912A71 /* SpliceOutProblem.swift in Sources */, 53BEFD54160278C5E393E319 /* HomeView.swift in Sources */, DCD1208728663F4A00EB39C5 /* TransactionsView.swift in Sources */, DC118BFC27B4504B0080BBAC /* ScanView.swift in Sources */, @@ -1584,7 +1649,6 @@ DC1B325029FC3D5900F7F45F /* OnChainDetails.swift in Sources */, DC39D4E7286BB2130030F18D /* PaymentLayerChoice.swift in Sources */, DCAC9FC6296879770098D769 /* NavigationStackDestination.swift in Sources */, - DC27E4C22791C00F00C777CC /* PrivacyView.swift in Sources */, DC46BAF626CACCF700E760A6 /* KotlinFutures.swift in Sources */, DC641C7E2821744000862DCD /* Currency+CurrencyPrefs.swift in Sources */, DC175C1C28F008AE0086B9A6 /* WalletReset.swift in Sources */, @@ -1600,6 +1664,7 @@ DCE6FB8C28D0B5F200054511 /* ResetWalletView.swift in Sources */, DCE7232E27AD68CD0017CF56 /* SyncTxManager_Actor.swift in Sources */, DC16965F27FE0FAC003DE1DD /* KotlinExtensions+Currency.swift in Sources */, + DC65BBF22A58A40700EBA651 /* CpfpView.swift in Sources */, DC422F3329392ABD00E72253 /* Date+Format.swift in Sources */, DCEFD922276A796800001767 /* SyncManager.swift in Sources */, DC2F431C27B69BB20006FCC4 /* ReceiveLightningView.swift in Sources */, @@ -1610,6 +1675,7 @@ DC641C6D2820826000862DCD /* AppMigration.swift in Sources */, DC08A51827FB39530041603B /* AnimatedChevron.swift in Sources */, C8D7ABC95B979B59AF5A7CA7 /* InitializationView.swift in Sources */, + DCFAEFC92A72F48700330088 /* SwapInWalletDetails.swift in Sources */, DC89857F25914747007B253F /* UIApplicationState+Phoenix.swift in Sources */, DCFC72042862237400D6B293 /* Asserts.swift in Sources */, DCCD046127EE045C007D57A5 /* DetailsView.swift in Sources */, @@ -1630,7 +1696,7 @@ C8D7A74B29EAFF2EBD73BC6B /* ConfigurationView.swift in Sources */, DC46BAF826CACCF700E760A6 /* KotlinAssociatedObject.swift in Sources */, DC0994B0263A074C003031CA /* InfoGrid.swift in Sources */, - DC46BAF526CACCF700E760A6 /* KotlinPublishers.swift in Sources */, + DC46BAF526CACCF700E760A6 /* KotlinPublishers+Phoenix.swift in Sources */, C8D7ABB189B14D3104ABB50D /* AboutView.swift in Sources */, DCCD045F27EE0301007D57A5 /* SummaryView.swift in Sources */, C8D7AA4B09B32AD99C88BB5E /* DisplayConfigurationView.swift in Sources */, @@ -1661,7 +1727,6 @@ DC09086325B626B300A46136 /* AppStatusPopover.swift in Sources */, DC0732EC263CA6C3004CB88D /* PaymentOptionsView.swift in Sources */, DC49DA8E258BB882005BC4BC /* ScaledButtonStyle.swift in Sources */, - DC87806F292D69C90061715B /* IncomingDepositPopover.swift in Sources */, DCAEF8D9275E69B000015993 /* SyncSeedManager_State.swift in Sources */, DCB04685260D162C007FDA37 /* ViewName.swift in Sources */, DCB30E522A0A948000E7D7A2 /* WalletInfoView.swift in Sources */, @@ -1682,7 +1747,9 @@ DCAC9FC329675E1A0098D769 /* NavigationWrapper.swift in Sources */, DC65D86428E2F7D700686355 /* ResetWalletView_Action.swift in Sources */, DC71E7332728645B0063613D /* CurrencyConverterView.swift in Sources */, + DCA3B41F2A5471C900E6B231 /* MinerFeeInfo.swift in Sources */, DCACF6F02566D0A60009B01E /* Data+Hexadecimal.swift in Sources */, + DCE3C7AB2A6AD3CC00F4D385 /* MempoolMonitor.swift in Sources */, DCACF7092566D0F00009B01E /* AppAccessView.swift in Sources */, DC27E4D1279753EC00C777CC /* TextFieldNumberStyler.swift in Sources */, DC46CB1628D9F30500C4EAC7 /* LoadingView.swift in Sources */, @@ -1691,17 +1758,19 @@ DCCCEAB528F6DA2A0047871A /* RootView.swift in Sources */, DCDAA7402971C29700B406A8 /* RecentPaymentsSelector.swift in Sources */, DCD5FF4326A0D34B009CC666 /* EqualSizes.swift in Sources */, + DCB62F472A5DF19D00912A71 /* KotlinPublishers+Lightning.swift in Sources */, DC355E252A45FDD3008E8A8E /* NoticeMonitor.swift in Sources */, F4AED298257A50CD009485C1 /* LogsConfigurationView.swift in Sources */, DC08A51A27FB6C5F0041603B /* TopTab.swift in Sources */, DC27E4CF2792079600C777CC /* CenterTopLineAlignment.swift in Sources */, DCEAE5B92943D64B00320C46 /* MsatRange.swift in Sources */, DC118BF827B44E6E0080BBAC /* LoginView.swift in Sources */, + DC43096E2A7953F400E28995 /* FinalWalletDetails.swift in Sources */, DC74174D270F455D00F7E3E3 /* AES256.swift in Sources */, C8D7AB09E80CE2B6AE270A97 /* TorConfigurationView.swift in Sources */, DC27E4C42791C58C00C777CC /* PaymentsBackupView.swift in Sources */, DC59377127516297003B4B53 /* Sequence+Sum.swift in Sources */, - DCA125752A27EDDB00DA2F7F /* MempoolSpace.swift in Sources */, + DCA125752A27EDDB00DA2F7F /* MempoolRecommendedResponse.swift in Sources */, DC46CB1228D9AAB000C4EAC7 /* LockState.swift in Sources */, DC2F431427B6972C0006FCC4 /* SwapInView.swift in Sources */, DC71E7352728A5720063613D /* KotlinIdentifiable.swift in Sources */, @@ -1739,7 +1808,7 @@ DCA6DEC82829C3150073C658 /* GenericPasswordStore.swift in Sources */, DCA6DECD282AB10C0073C658 /* SharedSecurity.swift in Sources */, DCEB2799282D7B260096B87E /* KotlinExtensions+Conversion.swift in Sources */, - DCEB2795282D7A9F0096B87E /* KotlinPublishers.swift in Sources */, + DCEB2795282D7A9F0096B87E /* KotlinPublishers+Phoenix.swift in Sources */, DC641C6B2820803100862DCD /* PhoenixManager.swift in Sources */, DC641C7B2821726F00862DCD /* FormattedAmount.swift in Sources */, DC641C77282171D200862DCD /* KotlinExtensions+Currency.swift in Sources */, diff --git a/phoenix-ios/phoenix-ios/extensions/Int+ToDate.swift b/phoenix-ios/phoenix-ios/extensions/Int+ToDate.swift index de2a35808..0a2255177 100644 --- a/phoenix-ios/phoenix-ios/extensions/Int+ToDate.swift +++ b/phoenix-ios/phoenix-ios/extensions/Int+ToDate.swift @@ -15,3 +15,10 @@ extension Int64 { } } } + +extension Date { + + func toMilliseconds() -> Int64 { + return Int64(self.timeIntervalSince1970 * 1_000) + } +} diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Currency.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Currency.swift index c3293f38d..e5d50efe2 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Currency.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Currency.swift @@ -8,16 +8,42 @@ extension FiatCurrency { return code + mkt } - var splitShortName: (String, String) { + var splitShortName: (String, String) { // e.g. ("ARS", "off") - if name.count <= 3 { - return (name.uppercased(), "") + return FiatCurrency.shortNameComponents(displayCode) + } + + var shortPreciseName: String { + let (code, mkt) = splitShortPreciseName + return code + mkt + } + + var splitShortPreciseName: (String, String) { // e.g. ("ARM", "bm") + + let precise: String + switch self { + case .ars : precise = "ARS_OFF" + case .arsBm : precise = "ARS_BM" + case .cup : precise = "CUP_OFF" + case .cupFm : precise = "CUP_FM" + case .lbp : precise = "LBP_OFF" + case .lbpBm : precise = "LBP_BM" + default : precise = self.displayCode + } + + return FiatCurrency.shortNameComponents(precise) + } + + private static func shortNameComponents(_ input: String) -> (String, String) { + + if input.count <= 3 { + return (input.uppercased(), "") } else { // E.g. "ARS_BM" - let splitIdx = name.index(name.startIndex, offsetBy: 3) + let splitIdx = input.index(input.startIndex, offsetBy: 3) - let code = name[name.startIndex ..< splitIdx] - let mkt = name[splitIdx ..< name.endIndex] + let code = input[input.startIndex ..< splitIdx] + let mkt = input[splitIdx ..< input.endIndex] .trimmingCharacters(in: CharacterSet.alphanumerics.inverted) return (code.uppercased(), mkt.lowercased()) @@ -31,8 +57,8 @@ extension FiatCurrency { case .amd : return "Armenian Dram" case .ang : return "Netherlands Antillean Guilder" case .aoa : return "Angolan Kwanza" - case .arsBm : return "Argentine Peso (blue market)" - case .ars : return "Argentine Peso" + case .ars : fallthrough + case .arsBm : return "Argentine Peso" case .aud : return "Australian Dollar" case .awg : return "Aruban Florin" case .azn : return "Azerbaijani Manat" @@ -58,8 +84,8 @@ extension FiatCurrency { case .cny : return "Chinese Yuan" case .cop : return "Colombian Peso" case .crc : return "Costa Rican Colรณn" - case .cup : return "Cuban Peso" - case .cupFm : return "Cuban Peso (free market)" + case .cup : fallthrough + case .cupFm : return "Cuban Peso" case .cve : return "Cape Verdean Escudo" case .czk : return "Czech Koruna" case .djf : return "Djiboutian Franc" @@ -105,8 +131,8 @@ extension FiatCurrency { case .kyd : return "Cayman Islands Dollar" case .kzt : return "Kazakhstani Tenge" case .lak : return "Laotian Kip" - case .lbp : return "Lebanese Pound" - case .lbpBm : return "Lebanese Pound (black market)" + case .lbp : fallthrough + case .lbpBm : return "Lebanese Pound" case .lkr : return "Sri Lankan Rupee" case .lrd : return "Liberian Dollar" case .lsl : return "Lesotho Loti" @@ -189,8 +215,8 @@ extension FiatCurrency { case .amd : return NSLocalizedString("AMD", tableName: "Currencies", comment: "Armenian Dram") case .ang : return NSLocalizedString("ANG", tableName: "Currencies", comment: "Netherlands Antillean Guilder") case .aoa : return NSLocalizedString("AOA", tableName: "Currencies", comment: "Angolan Kwanza") - case .arsBm : return NSLocalizedString("ARSbm", tableName: "Currencies", comment: "Argentine Peso (blue market)") - case .ars : return NSLocalizedString("ARS", tableName: "Currencies", comment: "Argentine Peso") + case .ars : fallthrough + case .arsBm : return NSLocalizedString("ARS", tableName: "Currencies", comment: "Argentine Peso") case .aud : return NSLocalizedString("AUD", tableName: "Currencies", comment: "Australian Dollar") case .awg : return NSLocalizedString("AWG", tableName: "Currencies", comment: "Aruban Florin") case .azn : return NSLocalizedString("AZN", tableName: "Currencies", comment: "Azerbaijani Manat") @@ -216,8 +242,8 @@ extension FiatCurrency { case .cny : return NSLocalizedString("CNY", tableName: "Currencies", comment: "Chinese Yuan (onshore)") case .cop : return NSLocalizedString("COP", tableName: "Currencies", comment: "Colombian Peso") case .crc : return NSLocalizedString("CRC", tableName: "Currencies", comment: "Costa Rican Colรณn") - case .cup : return NSLocalizedString("CUP", tableName: "Currencies", comment: "Cuban Peso") - case .cupFm : return NSLocalizedString("CUPfm", tableName: "Currencies", comment: "Cuban Peso (free market)") + case .cup : fallthrough + case .cupFm : return NSLocalizedString("CUP", tableName: "Currencies", comment: "Cuban Peso") case .cve : return NSLocalizedString("CVE", tableName: "Currencies", comment: "Cape Verdean Escudo") case .czk : return NSLocalizedString("CZK", tableName: "Currencies", comment: "Czech Koruna") case .djf : return NSLocalizedString("DJF", tableName: "Currencies", comment: "Djiboutian Franc") @@ -263,8 +289,8 @@ extension FiatCurrency { case .kyd : return NSLocalizedString("KYD", tableName: "Currencies", comment: "Cayman Islands Dollar") case .kzt : return NSLocalizedString("KZT", tableName: "Currencies", comment: "Kazakhstani Tenge") case .lak : return NSLocalizedString("LAK", tableName: "Currencies", comment: "Laotian Kip") - case .lbp : return NSLocalizedString("LBP", tableName: "Currencies", comment: "Lebanese Pound") - case .lbpBm : return NSLocalizedString("LBPbm", tableName: "Currencies", comment: "Lebanese Pound (black market)") + case .lbp : fallthrough + case .lbpBm : return NSLocalizedString("LBP", tableName: "Currencies", comment: "Lebanese Pound") case .lkr : return NSLocalizedString("LKR", tableName: "Currencies", comment: "Sri Lankan Rupee") case .lrd : return NSLocalizedString("LRD", tableName: "Currencies", comment: "Liberian Dollar") case .lsl : return NSLocalizedString("LSL", tableName: "Currencies", comment: "Lesotho Loti") @@ -340,180 +366,52 @@ extension FiatCurrency { default : return "" }} + private var longName_marketTranslation: String { switch self { + case .ars : return NSLocalizedString("official rate", tableName: "Currencies", comment: "") + case .arsBm : return NSLocalizedString("blue market", tableName: "Currencies", comment: "") + case .cup : return NSLocalizedString("official rate", tableName: "Currencies", comment: "") + case .cupFm : return NSLocalizedString("free market", tableName: "Currencies", comment: "") + case .lbp : return NSLocalizedString("official rate", tableName: "Currencies", comment: "") + case .lbpBm : return NSLocalizedString("black market", tableName: "Currencies", comment: "") + default : return "" + }} + var longName: String { - let manualTranslation = longName_manualTranslation - if !manualTranslation.isEmpty && manualTranslation != self.shortName { - return manualTranslation + let (name, mkt) = splitLongName + + if mkt.isEmpty { + return name + } else { + return "\(name) (\(mkt))" } + } + + var splitLongName: (String, String) { // e.g. ("Argentine Peso", "blue market") - var autoTranslation: String? = nil + let (code, _) = splitShortName + let manualTranslation = longName_manualTranslation + if !manualTranslation.isEmpty && manualTranslation != code { + return (manualTranslation, longName_marketTranslation) + } + + var autoTranslation: String? = nil + let currentLocale = Locale.current if currentLocale.languageCode != "en" { - autoTranslation = currentLocale.localizedString(forCurrencyCode: self.shortName) + let (code, mkt) = splitShortPreciseName + if mkt.isEmpty { + autoTranslation = currentLocale.localizedString(forCurrencyCode: code) + } + } + + if let autoTranslation { + return (autoTranslation, longName_marketTranslation) + } else { + return (longName_englishTranslation, longName_marketTranslation) } - - return autoTranslation ?? longName_englishTranslation } - var flag: String { switch self { - case .aed : return "๐Ÿ‡ฆ๐Ÿ‡ช" // United Arab Emirates Dirham - case .afn : return "๐Ÿ‡ฆ๐Ÿ‡ซ" // Afghan Afghani - case .all : return "๐Ÿ‡ฆ๐Ÿ‡ฑ" // Albanian Lek - case .amd : return "๐Ÿ‡ฆ๐Ÿ‡ฒ" // Armenian Dram - case .ang : return "๐Ÿ‡ณ๐Ÿ‡ฑ" // Netherlands Antillean Guilder - case .aoa : return "๐Ÿ‡ฆ๐Ÿ‡ด" // Angolan Kwanza - case .arsBm : return "๐Ÿ‡ฆ๐Ÿ‡ท" // Argentine Peso (blue market) - case .ars : return "๐Ÿ‡ฆ๐Ÿ‡ท" // Argentine Peso - case .aud : return "๐Ÿ‡ฆ๐Ÿ‡บ" // Australian Dollar - case .awg : return "๐Ÿ‡ฆ๐Ÿ‡ผ" // Aruban Florin - case .azn : return "๐Ÿ‡ฆ๐Ÿ‡ฟ" // Azerbaijani Manat - case .bam : return "๐Ÿ‡ง๐Ÿ‡ฆ" // Bosnia-Herzegovina Convertible Mark - case .bbd : return "๐Ÿ‡ง๐Ÿ‡ง" // Barbadian Dollar - case .bdt : return "๐Ÿ‡ง๐Ÿ‡ฉ" // Bangladeshi Taka - case .bgn : return "๐Ÿ‡ง๐Ÿ‡ฌ" // Bulgarian Lev - case .bhd : return "๐Ÿ‡ง๐Ÿ‡ญ" // Bahraini Dinar - case .bif : return "๐Ÿ‡ง๐Ÿ‡ฎ" // Burundian Franc - case .bmd : return "๐Ÿ‡ง๐Ÿ‡ฒ" // Bermudan Dollar - case .bnd : return "๐Ÿ‡ง๐Ÿ‡ณ" // Brunei Dollar - case .bob : return "๐Ÿ‡ง๐Ÿ‡ด" // Bolivian Boliviano - case .brl : return "๐Ÿ‡ง๐Ÿ‡ท" // Brazilian Real - case .bsd : return "๐Ÿ‡ง๐Ÿ‡ธ" // Bahamian Dollar - case .btn : return "๐Ÿ‡ง๐Ÿ‡น" // Bhutanese Ngultrum - case .bwp : return "๐Ÿ‡ง๐Ÿ‡ผ" // Botswanan Pula - case .bzd : return "๐Ÿ‡ง๐Ÿ‡ฟ" // Belize Dollar - case .cad : return "๐Ÿ‡จ๐Ÿ‡ฆ" // Canadian Dollar - case .cdf : return "๐Ÿ‡จ๐Ÿ‡ฉ" // Congolese Franc - case .chf : return "๐Ÿ‡จ๐Ÿ‡ญ" // Swiss Franc - case .clp : return "๐Ÿ‡จ๐Ÿ‡ฑ" // Chilean Peso - case .cnh : return "๐Ÿ‡จ๐Ÿ‡ณ" // Chinese Yuan (offshore) - case .cny : return "๐Ÿ‡จ๐Ÿ‡ณ" // Chinese Yuan (onshore) - case .cop : return "๐Ÿ‡จ๐Ÿ‡ด" // Colombian Peso - case .crc : return "๐Ÿ‡จ๐Ÿ‡ท" // Costa Rican Colรณn - case .cup : return "๐Ÿ‡จ๐Ÿ‡บ" // Cuban Peso - case .cupFm : return "๐Ÿ‡จ๐Ÿ‡บ" // Cuban Peso (free market) - case .cve : return "๐Ÿ‡จ๐Ÿ‡ป" // Cape Verdean Escudo - case .czk : return "๐Ÿ‡จ๐Ÿ‡ฟ" // Czech Koruna - case .djf : return "๐Ÿ‡ฉ๐Ÿ‡ฏ" // Djiboutian Franc - case .dkk : return "๐Ÿ‡ฉ๐Ÿ‡ฐ" // Danish Krone - case .dop : return "๐Ÿ‡ฉ๐Ÿ‡ด" // Dominican Peso - case .dzd : return "๐Ÿ‡ฉ๐Ÿ‡ฟ" // Algerian Dinar - case .egp : return "๐Ÿ‡ช๐Ÿ‡ฌ" // Egyptian Pound - case .ern : return "๐Ÿ‡ช๐Ÿ‡ท" // Eritrean Nakfa - case .etb : return "๐Ÿ‡ช๐Ÿ‡น" // Ethiopian Birr - case .eur : return "๐Ÿ‡ช๐Ÿ‡บ" // Euro - case .fjd : return "๐Ÿ‡ซ๐Ÿ‡ฏ" // Fijian Dollar - case .fkp : return "๐Ÿ‡ซ๐Ÿ‡ฐ" // Falkland Islands Pound - case .gbp : return "๐Ÿ‡ฌ๐Ÿ‡ง" // British Pound Sterling - case .gel : return "๐Ÿ‡ฌ๐Ÿ‡ช" // Georgian Lari - case .ghs : return "๐Ÿ‡ฌ๐Ÿ‡ญ" // Ghanaian Cedi - case .gip : return "๐Ÿ‡ฌ๐Ÿ‡ฎ" // Gibraltar Pound - case .gmd : return "๐Ÿ‡ฌ๐Ÿ‡ฒ" // Gambian Dalasi - case .gnf : return "๐Ÿ‡ฌ๐Ÿ‡ณ" // Guinean Franc - case .gtq : return "๐Ÿ‡ฌ๐Ÿ‡น" // Guatemalan Quetzal - case .gyd : return "๐Ÿ‡ฌ๐Ÿ‡พ" // Guyanaese Dollar - case .hkd : return "๐Ÿ‡ญ๐Ÿ‡ฐ" // Hong Kong Dollar - case .hnl : return "๐Ÿ‡ญ๐Ÿ‡ณ" // Honduran Lempira - case .hrk : return "๐Ÿ‡ญ๐Ÿ‡ท" // Croatian Kuna - case .htg : return "๐Ÿ‡ญ๐Ÿ‡น" // Haitian Gourde - case .huf : return "๐Ÿ‡ญ๐Ÿ‡บ" // Hungarian Forint - case .idr : return "๐Ÿ‡ฎ๐Ÿ‡ฉ" // Indonesian Rupiah - case .ils : return "๐Ÿ‡ฎ๐Ÿ‡ฑ" // Israeli New Sheqel - case .inr : return "๐Ÿ‡ฎ๐Ÿ‡ณ" // Indian Rupee - case .iqd : return "๐Ÿ‡ฎ๐Ÿ‡ถ" // Iraqi Dinar - case .irr : return "๐Ÿ‡ฎ๐Ÿ‡ท" // Iranian Rial - case .isk : return "๐Ÿ‡ฎ๐Ÿ‡ธ" // Icelandic Krรณna - case .jep : return "๐Ÿ‡ฏ๐Ÿ‡ช" // Jersey Pound - case .jmd : return "๐Ÿ‡ฏ๐Ÿ‡ฒ" // Jamaican Dollar - case .jod : return "๐Ÿ‡ฏ๐Ÿ‡ด" // Jordanian Dinar - case .jpy : return "๐Ÿ‡ฏ๐Ÿ‡ต" // Japanese Yen - case .kes : return "๐Ÿ‡ฐ๐Ÿ‡ช" // Kenyan Shilling - case .kgs : return "๐Ÿ‡ฐ๐Ÿ‡ฌ" // Kyrgystani Som - case .khr : return "๐Ÿ‡ฐ๐Ÿ‡ญ" // Cambodian Riel - case .kmf : return "๐Ÿ‡ฐ๐Ÿ‡ฒ" // Comorian Franc - case .kpw : return "๐Ÿ‡ฐ๐Ÿ‡ต" // North Korean Won - case .krw : return "๐Ÿ‡ฐ๐Ÿ‡ท" // South Korean Won - case .kwd : return "๐Ÿ‡ฐ๐Ÿ‡ผ" // Kuwaiti Dinar - case .kyd : return "๐Ÿ‡ฐ๐Ÿ‡พ" // Cayman Islands Dollar - case .kzt : return "๐Ÿ‡ฐ๐Ÿ‡ฟ" // Kazakhstani Tenge - case .lak : return "๐Ÿ‡ฑ๐Ÿ‡ฆ" // Laotian Kip - case .lbp : return "๐Ÿ‡ฑ๐Ÿ‡ง" // Lebanese Pound - case .lbpBm : return "๐Ÿ‡ฑ๐Ÿ‡ง" // Lebanese Pound (black market) - case .lkr : return "๐Ÿ‡ฑ๐Ÿ‡ฐ" // Sri Lankan Rupee - case .lrd : return "๐Ÿ‡ฑ๐Ÿ‡ท" // Liberian Dollar - case .lsl : return "๐Ÿ‡ฑ๐Ÿ‡ธ" // Lesotho Loti - case .lyd : return "๐Ÿ‡ฑ๐Ÿ‡พ" // Libyan Dinar - case .mad : return "๐Ÿ‡ฒ๐Ÿ‡ฆ" // Moroccan Dirham - case .mdl : return "๐Ÿ‡ฒ๐Ÿ‡ฉ" // Moldovan Leu - case .mga : return "๐Ÿ‡ฒ๐Ÿ‡ฌ" // Malagasy Ariary - case .mkd : return "๐Ÿ‡ฒ๐Ÿ‡ฐ" // Macedonian Denar - case .mmk : return "๐Ÿ‡ฒ๐Ÿ‡ฒ" // Myanmar Kyat - case .mnt : return "๐Ÿ‡ฒ๐Ÿ‡ณ" // Mongolian Tugrik - case .mop : return "๐Ÿ‡ฒ๐Ÿ‡ด" // Macanese Pataca - case .mur : return "๐Ÿ‡ฒ๐Ÿ‡บ" // Mauritian Rupee - case .mvr : return "๐Ÿ‡ฒ๐Ÿ‡ป" // Maldivian Rufiyaa - case .mwk : return "๐Ÿ‡ฒ๐Ÿ‡ผ" // Malawian Kwacha - case .mxn : return "๐Ÿ‡ฒ๐Ÿ‡ฝ" // Mexican Peso - case .myr : return "๐Ÿ‡ฒ๐Ÿ‡พ" // Malaysian Ringgit - case .mzn : return "๐Ÿ‡ฒ๐Ÿ‡ฟ" // Mozambican Metical - case .nad : return "๐Ÿ‡ณ๐Ÿ‡ฆ" // Namibian Dollar - case .ngn : return "๐Ÿ‡ณ๐Ÿ‡ฌ" // Nigerian Naira - case .nio : return "๐Ÿ‡ณ๐Ÿ‡ฎ" // Nicaraguan Cรณrdoba - case .nok : return "๐Ÿ‡ณ๐Ÿ‡ด" // Norwegian Krone - case .npr : return "๐Ÿ‡ณ๐Ÿ‡ต" // Nepalese Rupee - case .nzd : return "๐Ÿ‡ณ๐Ÿ‡ฟ" // New Zealand Dollar - case .omr : return "๐Ÿ‡ด๐Ÿ‡ฒ" // Omani Rial - case .pab : return "๐Ÿ‡ต๐Ÿ‡ฆ" // Panamanian Balboa - case .pen : return "๐Ÿ‡ต๐Ÿ‡ช" // Peruvian Nuevo Sol - case .pgk : return "๐Ÿ‡ต๐Ÿ‡ฌ" // Papua New Guinean Kina - case .php : return "๐Ÿ‡ต๐Ÿ‡ญ" // Philippine Peso - case .pkr : return "๐Ÿ‡ต๐Ÿ‡ฐ" // Pakistani Rupee - case .pln : return "๐Ÿ‡ต๐Ÿ‡ฑ" // Polish Zloty - case .pyg : return "๐Ÿ‡ต๐Ÿ‡พ" // Paraguayan Guarani - case .qar : return "๐Ÿ‡ถ๐Ÿ‡ฆ" // Qatari Rial - case .ron : return "๐Ÿ‡ท๐Ÿ‡ด" // Romanian Leu - case .rsd : return "๐Ÿ‡ท๐Ÿ‡ธ" // Serbian Dinar - case .rub : return "๐Ÿ‡ท๐Ÿ‡บ" // Russian Ruble - case .rwf : return "๐Ÿ‡ท๐Ÿ‡ผ" // Rwandan Franc - case .sar : return "๐Ÿ‡ธ๐Ÿ‡ฆ" // Saudi Riyal - case .sbd : return "๐Ÿ‡ธ๐Ÿ‡ง" // Solomon Islands Dollar - case .scr : return "๐Ÿ‡ธ๐Ÿ‡จ" // Seychellois Rupee - case .sdg : return "๐Ÿ‡ธ๐Ÿ‡ฉ" // Sudanese Pound - case .sek : return "๐Ÿ‡ธ๐Ÿ‡ช" // Swedish Krona - case .sgd : return "๐Ÿ‡ธ๐Ÿ‡ฌ" // Singapore Dollar - case .shp : return "๐Ÿ‡ธ๐Ÿ‡ญ" // Saint Helena Pound - case .sll : return "๐Ÿ‡ธ๐Ÿ‡ฑ" // Sierra Leonean Leone - case .sos : return "๐Ÿ‡ธ๐Ÿ‡ด" // Somali Shilling - case .srd : return "๐Ÿ‡ธ๐Ÿ‡ท" // Surinamese Dollar - case .syp : return "๐Ÿ‡ธ๐Ÿ‡พ" // Syrian Pound - case .szl : return "๐Ÿ‡ธ๐Ÿ‡ฟ" // Swazi Lilangeni - case .thb : return "๐Ÿ‡น๐Ÿ‡ญ" // Thai Baht - case .tjs : return "๐Ÿ‡น๐Ÿ‡ฏ" // Tajikistani Somoni - case .tmt : return "๐Ÿ‡น๐Ÿ‡ฒ" // Turkmenistani Manat - case .tnd : return "๐Ÿ‡น๐Ÿ‡ณ" // Tunisian Dinar - case .top : return "๐Ÿ‡น๐Ÿ‡ด" // Tongan Paสปanga - case .try_ : return "๐Ÿ‡น๐Ÿ‡ท" // Turkish Lira - case .ttd : return "๐Ÿ‡น๐Ÿ‡น" // Trinidad and Tobago Dollar - case .twd : return "๐Ÿ‡น๐Ÿ‡ผ" // New Taiwan Dollar - case .tzs : return "๐Ÿ‡น๐Ÿ‡ฟ" // Tanzanian Shilling - case .uah : return "๐Ÿ‡บ๐Ÿ‡ฆ" // Ukrainian Hryvnia - case .ugx : return "๐Ÿ‡บ๐Ÿ‡ฌ" // Ugandan Shilling - case .usd : return "๐Ÿ‡บ๐Ÿ‡ธ" // United States Dollar - case .uyu : return "๐Ÿ‡บ๐Ÿ‡พ" // Uruguayan Peso - case .uzs : return "๐Ÿ‡บ๐Ÿ‡ฟ" // Uzbekistan Som - case .vnd : return "๐Ÿ‡ป๐Ÿ‡ณ" // Vietnamese Dong - case .vuv : return "๐Ÿ‡ป๐Ÿ‡บ" // Vanuatu Vatu - case .wst : return "๐Ÿ‡ผ๐Ÿ‡ธ" // Samoan Tala - case .xaf : return "๐Ÿ‡จ๐Ÿ‡ฒ" // CFA Franc BEAC - multiple options, chose country with highest GDP - case .xcd : return "๐Ÿ‡ฑ๐Ÿ‡จ" // East Caribbean Dollar - multiple options, chose country with highest GDP - case .xof : return "๐Ÿ‡จ๐Ÿ‡ฎ" // CFA Franc BCEAO - multiple options, chose country with highest GDP - case .xpf : return "๐Ÿ‡ณ๐Ÿ‡จ" // CFP Franc - multiple options, chose country with highest GDP - case .yer : return "๐Ÿ‡พ๐Ÿ‡ช" // Yemeni Rial - case .zar : return "๐Ÿ‡ฟ๐Ÿ‡ฆ" // South African Rand - case .zmw : return "๐Ÿ‡ฟ๐Ÿ‡ฒ" // Zambian Kwacha - default : return "๐Ÿณ๏ธ" - }} - fileprivate struct _Key { static var matchingLocales = 0 static var usesCents = 1 @@ -521,6 +419,8 @@ extension FiatCurrency { func matchingLocales() -> [Locale] { + let (selfCurrencyCode, _) = self.splitShortName + return self.getSetAssociatedObject(storageKey: &_Key.matchingLocales) { var matchingLocales = [Locale]() @@ -528,7 +428,7 @@ extension FiatCurrency { let locale = Locale(identifier: identifier) if let currencyCode = locale.currencyCode, - currencyCode.caseInsensitiveCompare(self.name) == .orderedSame + currencyCode.caseInsensitiveCompare(selfCurrencyCode) == .orderedSame { matchingLocales.append(locale) } diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift index ba52c6fd2..30186831f 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift @@ -12,22 +12,22 @@ extension PhoenixBusiness { extension PeerManager { - func finalWalletBalance() -> Lightning_kmpWalletState.WalletWithConfirmations { + func finalWalletValue() -> Lightning_kmpWalletState.WalletWithConfirmations { if let value = self.finalWallet.value_ as? Lightning_kmpWalletState.WalletWithConfirmations { return value } else { - return Lightning_kmpWalletState.WalletWithConfirmations(minConfirmations: 1, currentBlockHeight: 1, all: []) + return Lightning_kmpWalletState.WalletWithConfirmations.empty() } } } extension BalanceManager { - func swapInWalletBalanceValue() -> WalletBalance { - if let value = swapInWalletBalance.value_ as? WalletBalance { + func swapInWalletValue() -> Lightning_kmpWalletState.WalletWithConfirmations { + if let value = self.swapInWallet.value_ as? Lightning_kmpWalletState.WalletWithConfirmations { return value } else { - return WalletBalance.companion.empty() + return Lightning_kmpWalletState.WalletWithConfirmations.empty() } } } @@ -69,11 +69,31 @@ extension Lightning_kmpWalletState.WalletWithConfirmations { return Bitcoin_kmpSatoshi(sat: balance) } - var confirmedBalance: Bitcoin_kmpSatoshi { - let anyConfirmed = weaklyConfirmed + deeplyConfirmed - let balance = anyConfirmed.map { $0.amount }.reduce(Int64(0)) { $0 + $1.toLong() } + var weaklyConfirmedBalance: Bitcoin_kmpSatoshi { + let balance = weaklyConfirmed.map { $0.amount }.reduce(Int64(0)) { $0 + $1.toLong() } return Bitcoin_kmpSatoshi(sat: balance) } + + var deeplyConfirmedBalance: Bitcoin_kmpSatoshi { + let balance = deeplyConfirmed.map { $0.amount }.reduce(Int64(0)) { $0 + $1.toLong() } + return Bitcoin_kmpSatoshi(sat: balance) + } + + var anyConfirmedBalance: Bitcoin_kmpSatoshi { + let anyConfirmedTx = weaklyConfirmed + deeplyConfirmed + let balance = anyConfirmedTx.map { $0.amount }.reduce(Int64(0)) { $0 + $1.toLong() } + return Bitcoin_kmpSatoshi(sat: balance) + } + + var totalBalance: Bitcoin_kmpSatoshi { + let allTx = unconfirmed + weaklyConfirmed + deeplyConfirmed + let balance = allTx.map { $0.amount }.reduce(Int64(0)) { $0 + $1.toLong() } + return Bitcoin_kmpSatoshi(sat: balance) + } + + static func empty() -> Lightning_kmpWalletState.WalletWithConfirmations { + return Lightning_kmpWalletState.WalletWithConfirmations(minConfirmations: 1, currentBlockHeight: 1, all: []) + } } extension Bitcoin_kmpByteVector32 { @@ -107,6 +127,28 @@ extension ConnectionsManager { return publisher } + + var asyncStream: AsyncStream { + + return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + + let swiftFlow = SwiftFlow(origin: self.connections) + + let watcher = swiftFlow.watch {(connections: Connections?) in + if let connections { + continuation.yield(connections) + } + } + + continuation.onTermination = { _ in + DispatchQueue.main.async { + // I'm not sure what thread this will be called from. + // And I've witnessed crashes when invoking `watcher.close()` from a non-main thread. + watcher.close() + } + } + } + } } extension Connections { diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinIdentifiable.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinIdentifiable.swift index d91ee54be..4fce3c8d6 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinIdentifiable.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinIdentifiable.swift @@ -48,3 +48,10 @@ extension FiatCurrency: Identifiable { return self.name } } + +extension Lightning_kmpWalletState.Utxo: Identifiable { + + public var id: String { + return "\(previousTx.txid.toHex()):\(outputIndex):\(blockHeight)" + } +} diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Lightning.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Lightning.swift new file mode 100644 index 000000000..473de5a1e --- /dev/null +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Lightning.swift @@ -0,0 +1,108 @@ +import Foundation +import Combine +import PhoenixShared +import os.log + +#if DEBUG && true +fileprivate var log = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: "KotlinPublishers+Lightning" +) +#else +fileprivate var log = Logger(OSLog.disabled) +#endif + + +// MARK: - +extension Lightning_kmpPeer { + + fileprivate struct _Key { + static var channelsPublisher = 0 + } + + typealias ChannelsMap = [Bitcoin_kmpByteVector32: Lightning_kmpChannelState] + + func channelsPublisher() -> AnyPublisher { + + self.getSetAssociatedObject(storageKey: &_Key.channelsPublisher) { + + /// Transforming from Kotlin: + /// `channelsFlow: StateFlow>` + /// + KotlinCurrentValueSubject( + self.channelsFlow + ) + .eraseToAnyPublisher() + } + } +} + +// MARK: - +extension Lightning_kmpElectrumClient { + + fileprivate struct _Key { + static var notificationsPublisher = 0 + } + + func notificationsPublisher() -> AnyPublisher { + + self.getSetAssociatedObject(storageKey: &_Key.notificationsPublisher) { + + /// Transforming from Kotlin: + /// `notifications: Flow` + /// + KotlinPassthroughSubject< + /*obj-c:*/ Lightning_kmpElectrumSubscriptionResponse, + /*swift:*/ Lightning_kmpElectrumSubscriptionResponse + >( + self.notifications + ) + .eraseToAnyPublisher() + } + } +} + +// MARK: - +extension Lightning_kmpElectrumWatcher { + + fileprivate struct _Key { + static var upToDatePublisher = 0 + } + + func upToDatePublisher() -> AnyPublisher { + + self.getSetAssociatedObject(storageKey: &_Key.upToDatePublisher) { + + /// Transforming from Kotlin: + /// `openUpToDateFlow(): Flow` + /// + KotlinPassthroughSubject( + self.openUpToDateFlow() + ) + .eraseToAnyPublisher() + } + } +} + +// MARK: - +extension Lightning_kmpNodeParams { + + fileprivate struct _Key { + static var nodeEventsPublisher = 0 + } + + func nodeEventsPublisher() -> AnyPublisher { + + self.getSetAssociatedObject(storageKey: &_Key.nodeEventsPublisher) { + + /// Transforming from Kotlin: + /// `nodeEvents: SharedFlow` + /// + KotlinPassthroughSubject( + self.nodeEvents + ) + .compactMap { $0 } + .eraseToAnyPublisher() + } + } +} diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift similarity index 82% rename from phoenix-ios/phoenix-ios/kotlin/KotlinPublishers.swift rename to phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift index 77480637d..cf9d3ec69 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift @@ -6,7 +6,7 @@ import os.log #if DEBUG && true fileprivate var log = Logger( subsystem: Bundle.main.bundleIdentifier!, - category: "KotlinPublishers" + category: "KotlinPublishers+Phoenix" ) #else fileprivate var log = Logger(OSLog.disabled) @@ -17,6 +17,7 @@ extension PeerManager { fileprivate struct _Key { static var peerStatePublisher = 0 static var channelsPublisher = 0 + static var finalWalletPublisher = 0 } func peerStatePublisher() -> AnyPublisher { @@ -51,6 +52,25 @@ extension PeerManager { .eraseToAnyPublisher() } } + + func finalWalletPublisher() -> AnyPublisher { + + self.getSetAssociatedObject(storageKey: &_Key.finalWalletPublisher) { + + // Transforming from Kotlin: + // ``` + // finalWallet: StateFlow + // ``` + KotlinCurrentValueSubject< + Lightning_kmpWalletState.WalletWithConfirmations, + Lightning_kmpWalletState.WalletWithConfirmations? + >( + self.finalWallet + ) + .map { $0 ?? Lightning_kmpWalletState.WalletWithConfirmations.empty() } + .eraseToAnyPublisher() + } + } } // MARK: - @@ -81,7 +101,7 @@ extension BalanceManager { fileprivate struct _Key { static var balancePublisher = 0 - static var swapInWalletBalancePublisher = 0 + static var swapInWalletPublisher = 0 } func balancePublisher() -> AnyPublisher { @@ -98,17 +118,21 @@ extension BalanceManager { } } - func swapInWalletBalancePublisher() -> AnyPublisher { + func swapInWalletPublisher() -> AnyPublisher { - self.getSetAssociatedObject(storageKey: &_Key.swapInWalletBalancePublisher) { + self.getSetAssociatedObject(storageKey: &_Key.swapInWalletPublisher) { // Transforming from Kotlin: // ``` - // swapInWalletBalance: StateFlow + // swapInWallet: StateFlow // ``` - KotlinCurrentValueSubject( - self.swapInWalletBalance + KotlinCurrentValueSubject< + Lightning_kmpWalletState.WalletWithConfirmations, + Lightning_kmpWalletState.WalletWithConfirmations? + >( + self.swapInWallet ) + .map { $0 ?? Lightning_kmpWalletState.WalletWithConfirmations.empty() } .eraseToAnyPublisher() } } @@ -358,72 +382,3 @@ extension CloudKitDb { } } } - -// MARK: - -extension Lightning_kmpPeer { - - fileprivate struct _Key { - static var channelsPublisher = 0 - } - - typealias ChannelsMap = [Bitcoin_kmpByteVector32: Lightning_kmpChannelState] - - func channelsPublisher() -> AnyPublisher { - - self.getSetAssociatedObject(storageKey: &_Key.channelsPublisher) { - - /// Transforming from Kotlin: - /// `channelsFlow: StateFlow>` - /// - KotlinCurrentValueSubject( - self.channelsFlow - ) - .eraseToAnyPublisher() - } - } -} - -// MARK: - -extension Lightning_kmpElectrumWatcher { - - fileprivate struct _Key { - static var upToDatePublisher = 0 - } - - func upToDatePublisher() -> AnyPublisher { - - self.getSetAssociatedObject(storageKey: &_Key.upToDatePublisher) { - - /// Transforming from Kotlin: - /// `openUpToDateFlow(): Flow` - /// - KotlinPassthroughSubject( - self.openUpToDateFlow() - ) - .eraseToAnyPublisher() - } - } -} - -// MARK: - -extension Lightning_kmpNodeParams { - - fileprivate struct _Key { - static var nodeEventsPublisher = 0 - } - - func nodeEventsPublisher() -> AnyPublisher { - - self.getSetAssociatedObject(storageKey: &_Key.nodeEventsPublisher) { - - /// Transforming from Kotlin: - /// `nodeEvents: SharedFlow` - /// - KotlinPassthroughSubject( - self.nodeEvents - ) - .compactMap { $0 } - .eraseToAnyPublisher() - } - } -} diff --git a/phoenix-ios/phoenix-ios/mempool/MempoolMonitor.swift b/phoenix-ios/phoenix-ios/mempool/MempoolMonitor.swift new file mode 100644 index 000000000..9cede5a5c --- /dev/null +++ b/phoenix-ios/phoenix-ios/mempool/MempoolMonitor.swift @@ -0,0 +1,239 @@ +import Foundation +import Combine +import Network +import os.log + +#if DEBUG && true +fileprivate var log = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: "MempoolMonitor" +) +#else +fileprivate var log = Logger(OSLog.disabled) +#endif + + +actor MempoolMonitor { + + static let shared = MempoolMonitor() + + private var listenerCount: UInt32 = 0 + private var refreshTask: Task? = nil + + /// Must use public `stream()` function defined below + private let responsePublisher = CurrentValueSubject(nil) + + /// Fires when the internet status switches from disconnected to connected. + private let internetReconnectedPublisher = PassthroughSubject() + + private let networkMonitor = NWPathMonitor() + + /// Must use shared instance + private init() { + log.trace("init()") + + Task { + await startNetworkMonitor() + } + } + + private func startNetworkMonitor() { + log.trace("startNetworkMonitor()") + + var wasDisconnected = true + networkMonitor.pathUpdateHandler = {(path: NWPath) -> Void in + + let hasInternet: Bool + switch path.status { + case .satisfied : hasInternet = true + case .unsatisfied : hasInternet = false + case .requiresConnection : hasInternet = true + @unknown default : hasInternet = false + } + + if hasInternet && wasDisconnected { + log.debug("Detected internet reconnection...") + self.internetReconnectedPublisher.send() + } + wasDisconnected = !hasInternet + } + + networkMonitor.start(queue: DispatchQueue.main) + } + + private func incrementListenerCount() { + log.trace("incrementListenerCount()") + + if listenerCount <= (UInt32.max - 1) { + listenerCount += 1 + } + + if listenerCount == 1 { + startRefreshTask() + } + } + + private func decrementListenerCount() { + log.trace("decrementListenerCount()") + + if listenerCount > 0 { + listenerCount -= 1 + } + + if listenerCount == 0 { + stopRefreshTask() + } + } + + private func startRefreshTask() { + log.trace("startRefreshTask()") + + guard refreshTask == nil else { + log.debug("startRefreshTask: already started") + return + } + + refreshTask = Task { + + // If there are active listeners, then we refresh every 5 minutes. + // If there are no active listeners, then this task is cancelled, and we don't refresh at all. + let successRefreshDelay = 5.minutes() + + // If we have a recent response from the server, then we don't need to immediately refresh. + if let lastResponse = self.responsePublisher.value { + let elapsed = lastResponse.timestamp.timeIntervalSinceNow * -1.0 + if elapsed < successRefreshDelay { + let delay = successRefreshDelay - elapsed + do { + log.debug("Last response is still fresh...") + try await Task.sleep(seconds: delay) + } catch {} + if Task.isCancelled { + return + } + } + } + + repeat { + + let result = await MempoolRecommendedResponse.fetch() + + switch result { + case .success(let response): + log.debug("Successfully refreshed mempool.space/recommended") + self.responsePublisher.send(response) + + do { + try await Task.sleep(seconds: successRefreshDelay) + } catch {} + + case .failure(let reason): + log.error("Errror fetching mempool.space/recommended: \(reason)") + + let delay = 15.seconds() + let sleepTask = Task { + try await Task.sleep(seconds: delay) + } + + // We might have failed due to an internet connectivity issue. + // So if the internet connection is restored, we should immediately retry. + let waitForInternetTask = Task { + for try await _ in self.internetReconnectedPublisher.values { + return sleepTask.cancel() + } + } + + do { + try await sleepTask.value + waitForInternetTask.cancel() + } catch {} + } + + } while !Task.isCancelled + } + } + + private func stopRefreshTask() { + log.trace("stopRefreshTask()") + + guard refreshTask != nil else { + log.debug("stopRefreshTask: already stopped") + return + } + + refreshTask?.cancel() + refreshTask = nil + } + + private func nextResponse( + _ previousResponse: MempoolRecommendedResponse? + ) async -> MempoolRecommendedResponse? { + + for try await response in self.responsePublisher.values { + if let response { + if let previousResponse { + // This is a subsequent request from the AsyncStream (e.g. second request). + // So we can only send the response if it's differnt from the previous response. + + if response != previousResponse { + + log.debug("Issuing fresh response...") + return response + } + + } else { + // This is the first time the AsyncStream has requested a value. + // So the response is the last cached value in the publisher. + // As long as it's not too old, we can relay it to the view. + // This allows the view to update itself with semi-accurate information. + // And the view will automatically refresh after we've refreshed the response. + + let elapsed = response.timestamp.timeIntervalSinceNow * -1.0 + if elapsed <= 1.hours() { + + log.debug("Re-issuing non-stale cached response...") + return response + } + } + } + } + + // The publisher above never finishes, so the code below is unreachable. + // But the compiler doesn't know that. + return nil + } + + nonisolated func stream() -> AsyncStream { + log.trace("stream()") + + Task { + await self.incrementListenerCount() + } + + var lastElement: MempoolRecommendedResponse? = nil + return AsyncStream(unfolding: { + + let element = await self.nextResponse(lastElement) + lastElement = element + return element + + }, onCancel: { + + Task { + await self.decrementListenerCount() + } + }) + } +} + +extension Task where Success == Never, Failure == Never { + + static func sleep(seconds: TimeInterval) async throws { + if #available(iOS 16.0, *) { + try await Task.sleep(for: Duration.seconds(seconds)) + } else { + let nanoseconds = UInt64(seconds * 1_000_000_000) + try await Task.sleep(nanoseconds: nanoseconds) + } + } +} diff --git a/phoenix-ios/phoenix-ios/views/send/MempoolSpace.swift b/phoenix-ios/phoenix-ios/mempool/MempoolRecommendedResponse.swift similarity index 50% rename from phoenix-ios/phoenix-ios/views/send/MempoolSpace.swift rename to phoenix-ios/phoenix-ios/mempool/MempoolRecommendedResponse.swift index bdb8e5c01..4e25ae669 100644 --- a/phoenix-ios/phoenix-ios/views/send/MempoolSpace.swift +++ b/phoenix-ios/phoenix-ios/mempool/MempoolRecommendedResponse.swift @@ -1,4 +1,6 @@ import Foundation +import PhoenixShared + enum MinerFeePriority { case none @@ -7,11 +9,23 @@ enum MinerFeePriority { case high } -struct MempoolRecommendedResponse: Codable { +struct MempoolRecommendedResponse: Equatable, Codable { let fastestFee: Double let halfHourFee: Double let hourFee: Double let economyFee: Double + let minimumFee: Double + + let timestamp: Date = Date.now // always using our own timestamp here + + // Tell compiler to ignore `timestamp` property when decoding + private enum CodingKeys: String, CodingKey { + case fastestFee + case halfHourFee + case hourFee + case economyFee + case minimumFee + } func feeForPriority(_ priority: MinerFeePriority) -> Double { switch priority { @@ -49,4 +63,21 @@ struct MempoolRecommendedResponse: Codable { return .success(response) } + + func toKotlin() -> MempoolFeerate { + + return MempoolFeerate( + fastest: Lightning_kmpFeeratePerByte(feerate: Bitcoin_kmpSatoshi(sat: Int64(fastestFee))), + halfHour: Lightning_kmpFeeratePerByte(feerate: Bitcoin_kmpSatoshi(sat: Int64(halfHourFee))), + hour: Lightning_kmpFeeratePerByte(feerate: Bitcoin_kmpSatoshi(sat: Int64(hourFee))), + economy: Lightning_kmpFeeratePerByte(feerate: Bitcoin_kmpSatoshi(sat: Int64(economyFee))), + minimum: Lightning_kmpFeeratePerByte(feerate: Bitcoin_kmpSatoshi(sat: Int64(minimumFee))), + timestamp: timestamp.toMilliseconds() + ) + } + + func swapEstimationFee(hasNoChannels: Bool) -> Bitcoin_kmpSatoshi { + + return self.toKotlin().swapEstimationFee(hasNoChannels: hasNoChannels) + } } diff --git a/phoenix-ios/phoenix-ios/officers/BusinessManager.swift b/phoenix-ios/phoenix-ios/officers/BusinessManager.swift index 5423d19ff..5d041361e 100644 --- a/phoenix-ios/phoenix-ios/officers/BusinessManager.swift +++ b/phoenix-ios/phoenix-ios/officers/BusinessManager.swift @@ -121,6 +121,7 @@ class BusinessManager { business = PhoenixBusiness(ctx: PlatformContext()) syncManager = nil + swapInRejectedPublisher.send(nil) walletInfo = nil peerConnectionState = nil paymentsPageFetchers.removeAll() @@ -135,50 +136,61 @@ class BusinessManager { private func registerForNotifications() { // Connection status observer - business.connectionsManager.publisher.sink { (connections: Connections) in - self.connectionsChanged(connections) - } - .store(in: &cancellables) + business.connectionsManager.publisher + .sink { (connections: Connections) in + + self.connectionsChanged(connections) + } + .store(in: &cancellables) // In-flight payments observer - business.paymentsManager.inFlightOutgoingPaymentsPublisher().sink { (count: Int) in - log.debug("inFlightOutgoingPaymentsPublisher: count = \(count)") - if count > 0 { - self.beginLongLivedTask() - } else { - self.endLongLivedTask() + business.paymentsManager.inFlightOutgoingPaymentsPublisher() + .sink { (count: Int) in + + log.debug("inFlightOutgoingPaymentsPublisher: count = \(count)") + if count > 0 { + self.beginLongLivedTask() + } else { + self.endLongLivedTask() + } } - } - .store(in: &cancellables) + .store(in: &cancellables) // Tor configuration observer - GroupPrefs.shared.isTorEnabledPublisher.sink { (isTorEnabled: Bool) in - self.business.appConfigurationManager.updateTorUsage(enabled: isTorEnabled) - } - .store(in: &cancellables) + GroupPrefs.shared.isTorEnabledPublisher + .sink { (isTorEnabled: Bool) in + + self.business.appConfigurationManager.updateTorUsage(enabled: isTorEnabled) + } + .store(in: &cancellables) // PreferredFiatCurrenies observers Publishers.CombineLatest( - GroupPrefs.shared.fiatCurrencyPublisher, - GroupPrefs.shared.currencyConverterListPublisher - ).sink { _ in - let current = AppConfigurationManager.PreferredFiatCurrencies( - primary: GroupPrefs.shared.fiatCurrency, - others: GroupPrefs.shared.preferredFiatCurrencies - ) - self.business.appConfigurationManager.updatePreferredFiatCurrencies(current: current) - } - .store(in: &cancellables) + GroupPrefs.shared.fiatCurrencyPublisher, + GroupPrefs.shared.currencyConverterListPublisher + ).sink { _ in + + let current = AppConfigurationManager.PreferredFiatCurrencies( + primary: GroupPrefs.shared.fiatCurrency, + others: GroupPrefs.shared.preferredFiatCurrencies + ) + self.business.appConfigurationManager.updatePreferredFiatCurrencies(current: current) + } + .store(in: &cancellables) // Liquidity policy - Prefs.shared.liquidityPolicyPublisher.dropFirst().sink { (policy: LiquidityPolicy) in + Prefs.shared.liquidityPolicyPublisher.dropFirst() + .sink { (policy: LiquidityPolicy) in - // let foo = policy.toKotlin() - // self.business.appConfigurationManager. ??? - // - // It's not possible to change the liquidity policy on-the-fly yet ? - } - .store(in: &cancellables) + Task { @MainActor in + do { + try await self.business.peerManager.updatePeerLiquidityPolicy(newPolicy: policy.toKotlin()) + } catch { + log.error("Error: biz.peerManager.updatePeerLiquidityPolicy: \(error)") + } + } + } + .store(in: &cancellables) // NodeEvents business.nodeParamsManager.nodeParamsPublisher() @@ -197,17 +209,17 @@ class BusinessManager { // LiquidityEvent.Accepted is still missing. // So we're simulating it by monitoring the swapIn wallet balance. // A LiquidityEvent.Rejected occurs when: - // - swapInWalletBalance.confirmed > 0 + // - swapInWallet.deeplyConfirmedBalance > 0 // - but the fees exceed the confirmed maxFees // // If the swapIn successfully occurs later, // then the entire confirmed balance is consumed, // and thus the confirmed balance drops to zero. // - business.balanceManager.swapInWalletBalancePublisher() - .sink { (balance: WalletBalance) in + business.balanceManager.swapInWalletPublisher() + .sink { (wallet: Lightning_kmpWalletState.WalletWithConfirmations) in - if balance.confirmed.sat == 0 { + if wallet.deeplyConfirmedBalance.sat == 0 { if self.swapInRejectedPublisher.value != nil { log.debug("Received Lightning_kmpLiquidityEventsAccepted") self.swapInRejectedPublisher.value = nil diff --git a/phoenix-ios/phoenix-ios/prefs/Prefs+BackupSeed.swift b/phoenix-ios/phoenix-ios/prefs/Prefs+BackupSeed.swift index 503ae465e..993f9eec9 100644 --- a/phoenix-ios/phoenix-ios/prefs/Prefs+BackupSeed.swift +++ b/phoenix-ios/phoenix-ios/prefs/Prefs+BackupSeed.swift @@ -165,9 +165,19 @@ extension Prefs { // So we're taking the simplest approach, and force-firing the associated PassthroughSubject publishers. let prefs = Prefs.shared - - prefs.backupSeed.hasUploadedSeed_publisher.send() - prefs.backupSeed.manualBackup_taskDone_publisher.send() + if #available(iOS 16, *) { + + prefs.backupSeed.hasUploadedSeed_publisher.send() + prefs.backupSeed.manualBackup_taskDone_publisher.send() + + } else { + + // Workaround for iOS 15 crash: Need to delay these calls until next runloop cycle + DispatchQueue.main.async { + prefs.backupSeed.hasUploadedSeed_publisher.send() + prefs.backupSeed.manualBackup_taskDone_publisher.send() + } + } }) .eraseToAnyPublisher() diff --git a/phoenix-ios/phoenix-ios/prefs/UserDefaults+Serialization.swift b/phoenix-ios/phoenix-ios/prefs/UserDefaults+Serialization.swift index cce157215..317765fcc 100644 --- a/phoenix-ios/phoenix-ios/prefs/UserDefaults+Serialization.swift +++ b/phoenix-ios/phoenix-ios/prefs/UserDefaults+Serialization.swift @@ -37,7 +37,7 @@ extension FiatCurrency { for fiat in FiatCurrency.companion.values { - let fiatCode = fiat.name // e.g. "AUD", "BRL" + let fiatCode = fiat.displayCode // e.g. "AUD", "BRL" if currencyCode.caseInsensitiveCompare(fiatCode) == .orderedSame { return fiat diff --git a/phoenix-ios/phoenix-ios/sync/SyncSeedManager.swift b/phoenix-ios/phoenix-ios/sync/SyncSeedManager.swift index 139b16756..a12056855 100644 --- a/phoenix-ios/phoenix-ios/sync/SyncSeedManager.swift +++ b/phoenix-ios/phoenix-ios/sync/SyncSeedManager.swift @@ -399,11 +399,7 @@ class SyncSeedManager: SyncManagerProtcol { let delay: TimeInterval = minElapsed - elapsed log.trace("uploadSeed(): readabilityDelay = \(delay) seconds") - if #available(iOS 16.0, *) { - try await Task.sleep(for: Duration.seconds(delay)) - } else { - try await Task.sleep(nanoseconds: UInt64(delay * Double(1_000_000_000))) - } + try await Task.sleep(seconds: delay) } // Since this is an async process, the user may have changed the seed name again diff --git a/phoenix-ios/phoenix-ios/sync/SyncTxManager.swift b/phoenix-ios/phoenix-ios/sync/SyncTxManager.swift index 6debcf1a2..f2fb1e8b4 100644 --- a/phoenix-ios/phoenix-ios/sync/SyncTxManager.swift +++ b/phoenix-ios/phoenix-ios/sync/SyncTxManager.swift @@ -571,7 +571,7 @@ class SyncTxManager { ] var done = false - var batch = 1 + var batch = 0 var cursor: CKQueryOperation.Cursor? = nil while !done { @@ -784,13 +784,34 @@ class SyncTxManager { } } - return UploadOperationInfo( + var opInfo = UploadOperationInfo( batch: batch, recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete, reverseMap: reverseMap, unpaddedMap: unpaddedMap ) + + // Edge-case: A rowid wasn't able to be converted to a WalletPaymentId. + // This may happen when we add a new type to the database, + // and we don't have code in place within `cloudKitDb.fetchQueueBatch()` to handle it. + // + // So the rowid is not represented in either `rowidMap` or `uniquePaymentIds()`. + // Nor is it reprensented in `recordsToSave` or `recordIDsToDelete`. + // + // The end result is that we have an empty operation. + // And we won't remove the rowid from the database either, creating an infinite loop. + // + // So we add a sanity check here. + + for rowid in batch.rowids { + if batch.rowidMap[rowid] == nil { + log.warning("UNHANDLED ROWID TYPE") + opInfo.completedRowids.append(rowid) + } + } + + return opInfo } // diff --git a/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift b/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift index e4b8c54ec..15b5566d6 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift @@ -14,23 +14,40 @@ fileprivate var log = Logger(OSLog.disabled) fileprivate enum NavLinkTag: String { // General - case AboutView - case DisplayConfigurationView - case MyWalletView - case RecoveryPhraseView - case PaymentOptionsView - case DrainWalletView - // Security - case AppAccessView + case About + case DisplayConfiguration + case PaymentOptions + case ChannelManagement + // Privacy & Security + case AppAccess + case RecoveryPhrase + case ElectrumServer + case Tor + case PaymentsBackup // Advanced - case PrivacyView - case ChannelsConfigurationView - case LogsConfigurationView - case ResetWalletView + case WalletInfo + case ChannelsConfiguration + case LogsConfiguration + // Danger Zone + case DrainWallet + case ResetWallet + case ForceCloseChannels } struct ConfigurationView: View { + @ViewBuilder + var body: some View { + ScrollViewReader { scrollViewProxy in + ConfigurationList(scrollViewProxy: scrollViewProxy) + } + } +} + +fileprivate struct ConfigurationList: View { + + let scrollViewProxy: ScrollViewProxy + @State var isFaceID = true @State var isTouchID = false @@ -45,13 +62,31 @@ struct ConfigurationView: View { @State private var swiftUiBugWorkaroundIdx = 0 @State var didAppear = false - @State var popToRootRequested = false + @State var popToDestination: PopToDestination? = nil + + @Namespace var linkID_About + @Namespace var linkID_DisplayConfiguration + @Namespace var linkID_PaymentOptions + @Namespace var linkID_ChannelManagement + @Namespace var linkID_AppAccess + @Namespace var linkID_RecoveryPhrase + @Namespace var linkID_ElectrumServer + @Namespace var linkID_Tor + @Namespace var linkID_PaymentsBackup + @Namespace var linkID_WalletInfo + @Namespace var linkID_ChannelsConfiguration + @Namespace var linkID_LogsConfiguration + @Namespace var linkID_DrainWallet + @Namespace var linkID_ResetWallet + @Namespace var linkID_ForceCloseChannels @Environment(\.presentationMode) var presentationMode: Binding @EnvironmentObject var deepLinkManager: DeepLinkManager - init() { + init(scrollViewProxy: ScrollViewProxy) { + + self.scrollViewProxy = scrollViewProxy if let encryptedNodeId = Biz.encryptedNodeId { backupSeedStatePublisher = Prefs.shared.backupSeedStatePublisher(encryptedNodeId) } else { @@ -78,10 +113,11 @@ struct ConfigurationView: View { let hasWallet = hasWallet() section_general(hasWallet) + section_privacyAndSecurity(hasWallet) + section_advanced(hasWallet) if hasWallet { - section_security() + section_dangerZone(hasWallet) } - section_advanced(hasWallet) } .listStyle(.insetGrouped) .listBackgroundColor(.primaryBackground) @@ -107,13 +143,14 @@ struct ConfigurationView: View { Section(header: Text("General")) { - navLink(.AboutView) { + navLink(.About) { Label { Text("About") } icon: { Image(systemName: "info.circle") } } + .id(linkID_About) - navLink(.DisplayConfigurationView) { + navLink(.DisplayConfiguration) { Label { switch notificationPermissions { case .disabled: @@ -132,19 +169,41 @@ struct ConfigurationView: View { Image(systemName: "paintbrush.pointed") } } + .id(linkID_DisplayConfiguration) + + navLink(.PaymentOptions) { + Label { Text("Payment options") } icon: { + Image(systemName: "wrench") + } + } + .id(linkID_PaymentOptions) + + navLink(.ChannelManagement) { + Label { Text("Channel management") } icon: { + Image(systemName: "wand.and.stars") + } + } + .id(linkID_ChannelManagement) + } // + } + + @ViewBuilder + func section_privacyAndSecurity(_ hasWallet: Bool) -> some View { + + Section(header: Text("Privacy & Security")) { + if hasWallet { - navLink(.MyWalletView) { - Label { - Text("My wallet") - } icon: { - Image(systemName: "person") + navLink(.AppAccess) { + Label { Text("App access") } icon: { + Image(systemName: isTouchID ? "touchid" : "faceid") } } - } + .id(linkID_AppAccess) + } // if hasWallet { - navLink(.RecoveryPhraseView) { + navLink(.RecoveryPhrase) { Label { switch backupSeedState { case .notBackedUp: @@ -165,40 +224,36 @@ struct ConfigurationView: View { Text("Recovery phrase") } } icon: { - Image(systemName: "squareshape.split.3x3") + Image(systemName: "key") } } - } - - navLink(.PaymentOptionsView) { - Label { Text("Options & fees") } icon: { - Image(systemName: "wrench") + .id(linkID_RecoveryPhrase) + } // + + navLink(.ElectrumServer) { + Label { Text("Electrum server") } icon: { + Image(systemName: "link") } } - - if hasWallet { - navLink(.DrainWalletView) { - Label { Text("Drain wallet") } icon: { - Image(systemName: "xmark.circle") - } + .id(linkID_ElectrumServer) + + navLink(.Tor) { + Label { Text("Tor") } icon: { + Image(systemName: "shield.lefthalf.fill") } } + .id(linkID_Tor) - } // - } - - @ViewBuilder - func section_security() -> some View { - - Section(header: Text("Security")) { - - navLink(.AppAccessView) { - Label { Text("App access") } icon: { - Image(systemName: isTouchID ? "touchid" : "faceid") + if hasWallet { + navLink(.PaymentsBackup) { + Label { Text("Payments backup") } icon: { + Image(systemName: "icloud.and.arrow.up") + } } - } + .id(linkID_PaymentsBackup) + } // - } // + } // } @ViewBuilder @@ -206,35 +261,69 @@ struct ConfigurationView: View { Section(header: Text("Advanced")) { - navLink(.PrivacyView) { - Label { Text("Privacy") } icon: { - Image(systemName: "eye") + if hasWallet { + navLink(.WalletInfo) { + Label { + Text("Wallet info") + } icon: { + Image(systemName: "cube") + } } + .id(linkID_WalletInfo) } - + if hasWallet { - navLink(.ChannelsConfigurationView) { + navLink(.ChannelsConfiguration) { Label { Text("Payment channels") } icon: { Image(systemName: "bolt") } } + .id(linkID_ChannelManagement) } - navLink(.LogsConfigurationView) { + navLink(.LogsConfiguration) { Label { Text("Logs") } icon: { Image(systemName: "doc.text") } } + .id(linkID_LogsConfiguration) + } // + } + + @ViewBuilder + func section_dangerZone(_ hasWallet: Bool) -> some View { + + Section(header: Text("Danger Zone")) { + if hasWallet { - navLink(.ResetWalletView) { + navLink(.DrainWallet) { + Label { Text("Drain wallet") } icon: { + Image(systemName: "xmark.circle") + } + } + .id(linkID_DrainWallet) + } + + if hasWallet { + navLink(.ResetWallet) { Label { Text("Reset wallet") } icon: { Image(systemName: "trash") } } + .id(linkID_ResetWallet) } - - } // + + if hasWallet { + navLink(.ForceCloseChannels) { + Label { Text("Force-close channels") } icon: { + Image(systemName: "exclamationmark.triangle") + } + .foregroundColor(.appNegative) + } + .id(linkID_ForceCloseChannels) + } + } // } @ViewBuilder @@ -249,6 +338,7 @@ struct ConfigurationView: View { selection: $navLinkTag, label: label ) + // .id(linkID(for: tag)) // doesn't compile - don't understand why } @ViewBuilder @@ -266,19 +356,24 @@ struct ConfigurationView: View { switch tag { // General - case .AboutView : AboutView() - case .DisplayConfigurationView : DisplayConfigurationView() - case .MyWalletView : WalletInfoView() - case .RecoveryPhraseView : RecoveryPhraseView() - case .PaymentOptionsView : PaymentOptionsView() - case .DrainWalletView : DrainWalletView(popToRoot: popToRoot) - // Security - case .AppAccessView : AppAccessView() + case .About : AboutView() + case .DisplayConfiguration : DisplayConfigurationView() + case .PaymentOptions : PaymentOptionsView() + case .ChannelManagement : LiquidityPolicyView() + // Privacy & Security + case .AppAccess : AppAccessView() + case .RecoveryPhrase : RecoveryPhraseView() + case .ElectrumServer : ElectrumConfigurationView() + case .Tor : TorConfigurationView() + case .PaymentsBackup : PaymentsBackupView() // Advanced - case .PrivacyView : PrivacyView() - case .ChannelsConfigurationView : ChannelsConfigurationView() - case .LogsConfigurationView : LogsConfigurationView() - case .ResetWalletView : ResetWalletView() + case .WalletInfo : WalletInfoView(popTo: popTo) + case .ChannelsConfiguration : ChannelsConfigurationView() + case .LogsConfiguration : LogsConfigurationView() + // Danger Zone + case .DrainWallet : DrainWalletView(popTo: popTo) + case .ResetWallet : ResetWalletView() + case .ForceCloseChannels : ForceCloseChannelsView() } } @@ -286,21 +381,6 @@ struct ConfigurationView: View { // MARK: View Helpers // -------------------------------------------------- - private func navLinkTagBinding(_ tag: NavLinkTag?) -> Binding { - - if let tag { // specific tag - return Binding( - get: { navLinkTag == tag }, - set: { if $0 { navLinkTag = tag } else if (navLinkTag == tag) { navLinkTag = nil } } - ) - } else { // any tag - return Binding( - get: { navLinkTag != nil }, - set: { if !$0 { navLinkTag = nil }} - ) - } - } - func hasWallet() -> Bool { return Biz.business.walletManager.isLoaded() @@ -329,18 +409,12 @@ struct ConfigurationView: View { if !didAppear { didAppear = true + if let deepLink = deepLinkManager.deepLink { DispatchQueue.main.async { deepLinkChanged(deepLink) } } - - } else { - - if popToRootRequested { - popToRootRequested = false - presentationMode.wrappedValue.dismiss() - } } } @@ -365,11 +439,12 @@ struct ConfigurationView: View { var delay: TimeInterval = 1.5 // seconds; multiply by number of screens we need to navigate switch value { case .paymentHistory : break - case .backup : newNavLinkTag = .RecoveryPhraseView ; delay *= 1 - case .drainWallet : newNavLinkTag = .DrainWalletView ; delay *= 1 - case .electrum : newNavLinkTag = .PrivacyView ; delay *= 2 - case .backgroundPayments : newNavLinkTag = .DisplayConfigurationView ; delay *= 2 - case .liquiditySettings : newNavLinkTag = .PaymentOptionsView ; delay *= 2 + case .backup : newNavLinkTag = .RecoveryPhrase ; delay *= 1 + case .drainWallet : newNavLinkTag = .DrainWallet ; delay *= 1 + case .electrum : newNavLinkTag = .ElectrumServer ; delay *= 1 + case .backgroundPayments : newNavLinkTag = .DisplayConfiguration ; delay *= 2 + case .liquiditySettings : newNavLinkTag = .ChannelManagement ; delay *= 1 + case .forceCloseChannels : newNavLinkTag = .ForceCloseChannels ; delay *= 1 } if let newNavLinkTag = newNavLinkTag { @@ -378,7 +453,17 @@ struct ConfigurationView: View { self.swiftUiBugWorkaroundIdx += 1 clearSwiftUiBugWorkaround(delay: delay) - self.navLinkTag = newNavLinkTag // Trigger/push the view + // Interesting bug in SwiftUI: + // If the navLinkTag you're targetting is scrolled off the screen, + // the you won't be able to navigate to it. + // My understanding is that List is lazy, and this somehow prevents triggering the navigation. + // The workaround is to manually scroll to the item to ensure it's onscreen, + // at which point we can activate the navLinkTag trigger. + // + scrollViewProxy.scrollTo(linkID(for: newNavLinkTag)) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + self.navLinkTag = newNavLinkTag // Trigger/push the view + } } } else { @@ -391,9 +476,36 @@ struct ConfigurationView: View { log.trace("navLinkTagChanged() => \(tag?.rawValue ?? "nil")") if tag == nil, let forcedNavLinkTag = swiftUiBugWorkaround { - + log.debug("Blocking SwiftUI's attempt to reset our navLinkTag") self.navLinkTag = forcedNavLinkTag + + } else if tag == nil { + + // If there's a pending popToDestination, it's now safe to continue the flow. + // + // Note that performing this operation in `onAppear` doesn't work properly: + // - it appears to work fine on the simulator, but isn't reliable on the actual device + // - it seems that, IF using a `navLinkTag`, then we need to wait for the tag to be + // unset before it can be set properly again. + // + if let destination = popToDestination { + log.debug("popToDestination: \(destination)") + + popToDestination = nil + switch destination { + case .RootView(_): + presentationMode.wrappedValue.dismiss() + + case .ConfigurationView(let deepLink): + if let deepLink { + deepLinkManager.broadcast(deepLink) + } + + case .TransactionsView: + log.warning("Invalid popToDestination") + } + } } } @@ -413,16 +525,40 @@ struct ConfigurationView: View { // MARK: Actions // -------------------------------------------------- - func popToRoot() { - log.trace("popToRoot") + func popTo(_ destination: PopToDestination) { + log.trace("popTo(\(destination))") - popToRootRequested = true + popToDestination = destination } // -------------------------------------------------- // MARK: Utilities // -------------------------------------------------- + func linkID(for navLinkTag: NavLinkTag) -> any Hashable { + + switch navLinkTag { + case .About : return linkID_About + case .DisplayConfiguration : return linkID_DisplayConfiguration + case .PaymentOptions : return linkID_PaymentOptions + case .ChannelManagement : return linkID_ChannelManagement + + case .AppAccess : return linkID_AppAccess + case .RecoveryPhrase : return linkID_RecoveryPhrase + case .ElectrumServer : return linkID_ElectrumServer + case .Tor : return linkID_Tor + case .PaymentsBackup : return linkID_PaymentsBackup + + case .WalletInfo : return linkID_WalletInfo + case .ChannelsConfiguration : return linkID_ChannelsConfiguration + case .LogsConfiguration : return linkID_LogsConfiguration + + case .DrainWallet : return linkID_DrainWallet + case .ResetWallet : return linkID_ResetWallet + case .ForceCloseChannels : return linkID_ForceCloseChannels + } + } + func clearSwiftUiBugWorkaround(delay: TimeInterval) { let idx = self.swiftUiBugWorkaroundIdx diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/channels/ChannelInfoPopup.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/channels/ChannelInfoPopup.swift index 54f3d1676..c2ac54c91 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/advanced/channels/ChannelInfoPopup.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/channels/ChannelInfoPopup.swift @@ -34,10 +34,9 @@ struct ChannelInfoPopup: View, ViewName { @ObservedObject var toast: Toast @State var selectedTab: Tab = .summary - @State var showBlockchainExplorerOptions = false @Environment(\.colorScheme) var colorScheme: ColorScheme - @Environment(\.popoverState) var popoverState: PopoverState + @EnvironmentObject var popoverState: PopoverState @ViewBuilder var body: some View { diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/channels/ChannelsConfigurationView.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/channels/ChannelsConfigurationView.swift index 58485a7a3..0d87df520 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/advanced/channels/ChannelsConfigurationView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/channels/ChannelsConfigurationView.swift @@ -106,9 +106,9 @@ fileprivate struct ChannelsView : View { @State var forceCloseChannelsOpen = false - @Environment(\.popoverState) var popoverState: PopoverState @Environment(\.presentationMode) var presentationMode: Binding + @EnvironmentObject var popoverState: PopoverState @EnvironmentObject var deepLinkManager: DeepLinkManager var body: some View { @@ -307,7 +307,7 @@ fileprivate struct ChannelRowView: View { @Binding var showChannelsRemoteBalance: Bool @ObservedObject var toast: Toast - @Environment(\.popoverState) var popoverState: PopoverState + @EnvironmentObject var popoverState: PopoverState var body: some View { @@ -418,30 +418,6 @@ fileprivate struct FooterView: View, ViewName { } } - HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { - - Text("Your Node ID:") - .font(.footnote) - .padding(.trailing, 4) - - Spacer() - - let nodeId = Biz.nodeId ?? "?" - Button { - copyNodeID(nodeId) - } label: { - HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 4) { - Text(nodeId) - .lineLimit(1) - .truncationMode(.middle) - - Image(systemName: "square.on.square") - .imageScale(.medium) - } - .font(.footnote) - } - } // - } // .frame(maxWidth: .infinity, alignment: .leading) .padding([.top, .bottom], 10) diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/privacy/PrivacyView.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/privacy/PrivacyView.swift deleted file mode 100644 index a71e6d3db..000000000 --- a/phoenix-ios/phoenix-ios/views/configuration/advanced/privacy/PrivacyView.swift +++ /dev/null @@ -1,175 +0,0 @@ -import SwiftUI -import PhoenixShared -import os.log - -#if DEBUG && true -fileprivate var log = Logger( - subsystem: Bundle.main.bundleIdentifier!, - category: "PrivacyView" -) -#else -fileprivate var log = Logger(OSLog.disabled) -#endif - - -fileprivate enum NavLinkTag: String { - case ElectrumConfigurationView - case TorView - case PaymentsBackupView -} - -struct PrivacyView: View { - - @State private var navLinkTag: NavLinkTag? = nil - - @EnvironmentObject var deepLinkManager: DeepLinkManager - - @State private var swiftUiBugWorkaround: NavLinkTag? = nil - @State private var swiftUiBugWorkaroundIdx = 0 - - @State var didAppear = false - - @ViewBuilder - var body: some View { - - content() - .navigationTitle(NSLocalizedString("Privacy", comment: "Navigation bar title")) - .navigationBarTitleDisplayMode(.inline) - } - - @ViewBuilder - func content() -> some View { - - let hasWallet = hasWallet() - - List { - NavigationLink( - destination: ElectrumConfigurationView(), - tag: NavLinkTag.ElectrumConfigurationView, - selection: $navLinkTag - ) { - Label { Text("Electrum server") } icon: { - Image(systemName: "link") - } - } - - NavigationLink( - destination: TorConfigurationView(), - tag: NavLinkTag.TorView, - selection: $navLinkTag - ) { - Label { Text("Tor") } icon: { - Image(systemName: "shield.lefthalf.fill") - } - } - - if hasWallet { - NavigationLink( - destination: PaymentsBackupView(), - tag: NavLinkTag.PaymentsBackupView, - selection: $navLinkTag - ) { - Label { Text("Payments backup") } icon: { - Image(systemName: "icloud.and.arrow.up") - } - } - } - - } // - .listStyle(.insetGrouped) - .listBackgroundColor(.primaryBackground) - .onAppear() { - onAppear() - } - .onChange(of: deepLinkManager.deepLink) { - deepLinkChanged($0) - } - .onChange(of: navLinkTag) { - navLinkTagChanged($0) - } - } - - func hasWallet() -> Bool { - - return Biz.business.walletManager.isLoaded() - } - - func onAppear() { - log.trace("onAppear()") - - if !didAppear { - didAppear = true - if let deepLink = deepLinkManager.deepLink { - DispatchQueue.main.async { // iOS 14 issues workaround - deepLinkChanged(deepLink) - } - } - - } - } - - func deepLinkChanged(_ value: DeepLink?) { - log.trace("deepLinkChanged() => \(value?.rawValue ?? "nil")") - - // This is a hack, courtesy of bugs in Apple's NavigationLink: - // https://developer.apple.com/forums/thread/677333 - // - // Summary: - // There's some quirky code in SwiftUI that is resetting our navLinkTag. - // Several bizarre workarounds have been proposed. - // I've tried every one of them, and none of them work (at least, without bad side-effects). - // - // The only clean solution I've found is to listen for SwiftUI's bad behaviour, - // and forcibly undo it. - - if let value = value { - - // Navigate towards deep link (if needed) - var newNavLinkTag: NavLinkTag? = nil - switch value { - case .paymentHistory : break - case .backup : break - case .drainWallet : break - case .electrum : newNavLinkTag = NavLinkTag.ElectrumConfigurationView - case .backgroundPayments : break - case .liquiditySettings : break - } - - if let newNavLinkTag = newNavLinkTag { - - self.swiftUiBugWorkaround = newNavLinkTag - self.swiftUiBugWorkaroundIdx += 1 - clearSwiftUiBugWorkaround(delay: 1.5) - - self.navLinkTag = newNavLinkTag // Trigger/push the view - } - - } else { - // We reached the final destination of the deep link - clearSwiftUiBugWorkaround(delay: 0.0) - } - } - - fileprivate func navLinkTagChanged(_ tag: NavLinkTag?) { - log.trace("navLinkTagChanged() => \(tag?.rawValue ?? "nil")") - - if tag == nil, let forcedNavLinkTag = swiftUiBugWorkaround { - - log.trace("Blocking SwiftUI's attempt to reset our navLinkTag") - self.navLinkTag = forcedNavLinkTag - } - } - - func clearSwiftUiBugWorkaround(delay: TimeInterval) { - - let idx = self.swiftUiBugWorkaroundIdx - - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - - if self.swiftUiBugWorkaroundIdx == idx { - log.trace("swiftUiBugWorkaround = nil") - self.swiftUiBugWorkaround = nil - } - } - } -} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/FinalWalletDetails.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/FinalWalletDetails.swift new file mode 100644 index 000000000..c41dc6d6e --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/FinalWalletDetails.swift @@ -0,0 +1,220 @@ +import SwiftUI +import PhoenixShared +import os.log + +#if DEBUG && true +fileprivate var log = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: "FinalWalletDetails" +) +#else +fileprivate var log = Logger(OSLog.disabled) +#endif + + +struct FinalWalletDetails: View { + + @State var finalWallet = Biz.business.peerManager.finalWalletValue() + let finalWalletPublisher = Biz.business.peerManager.finalWalletPublisher() + + @State var blockchainExplorerTxid: String? = nil + + @EnvironmentObject var currencyPrefs: CurrencyPrefs + + // -------------------------------------------------- + // MARK: View Builders + // -------------------------------------------------- + + @ViewBuilder + var body: some View { + + content() + .navigationTitle(NSLocalizedString("Final wallet", comment: "Navigation Bar Title")) + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + func content() -> some View { + + List { + section_info() + section_confirmed() + section_unconfirmed() + } + .listStyle(.insetGrouped) + .listBackgroundColor(.primaryBackground) + .onReceive(finalWalletPublisher) { + finalWalletChanged($0) + } + } + + @ViewBuilder + func section_info() -> some View { + + Section { + Text("The final wallet is where funds are sent by default when your Lightning channels are closed.") + } + } + + @ViewBuilder + func section_confirmed() -> some View { + + Section { + + let confirmed = confirmedBalance() + Text(verbatim: "\(confirmed.0.string)") + + Text(verbatim: " โ‰ˆ \(confirmed.1.string)").foregroundColor(.secondary) + + } header: { + Text("Confirmed Balance") + + } // + } + + @ViewBuilder + func section_unconfirmed() -> some View { + + Section { + + let utxos = unconfirmedUtxos() + if utxos.isEmpty { + + Text("No pending transactions") + .foregroundColor(.secondary) + + } else { + + ForEach(utxos) { utxo in + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + + Text(utxo.txid) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .padding(.trailing, 15) + + Group { + let (btcAmt, fiatAmt) = formattedBalances(utxo.amount) + + Text(verbatim: "\(btcAmt.string) ") + + Text(verbatim: " โ‰ˆ \(fiatAmt.string)").foregroundColor(.secondary) + } + .padding(.trailing, 4) + .layoutPriority(1) + + Spacer(minLength: 0) + + Button { + blockchainExplorerTxid = utxo.txid + } label: { + Image(systemName: "link") + } + } + } + } + + } header: { + Text("Unconfirmed Balance") + + } // + .confirmationDialog("Blockchain Explorer", + isPresented: confirmationDialogBinding(), + titleVisibility: .automatic + ) { + let txid = blockchainExplorerTxid ?? "" + Button { + exploreTx(txid, website: BlockchainExplorer.WebsiteMempoolSpace()) + } label: { + Text(verbatim: "Mempool.space") // no localization needed + .textCase(.none) + } + Button { + exploreTx(txid, website: BlockchainExplorer.WebsiteBlockstreamInfo()) + } label: { + Text(verbatim: "Blockstream.info") // no localization needed + .textCase(.none) + } + Button { + copyTxId(txid) + } label: { + Text("Copy transaction id") + .textCase(.none) + } + } // + } + + // -------------------------------------------------- + // MARK: View Helpers + // -------------------------------------------------- + + func confirmedBalance() -> (FormattedAmount, FormattedAmount) { + + let confirmed = finalWallet.anyConfirmedBalance + + let btcAmt = Utils.formatBitcoin(currencyPrefs, sat: confirmed) + let fiatAmt = Utils.formatFiat(currencyPrefs, sat: confirmed) + + return (btcAmt, fiatAmt) + } + + func unconfirmedUtxos() -> [UtxoWrapper] { + + let utxos = finalWallet.unconfirmed + let wrappedUtxos = utxos.map { utxo in + + let confirmationCount = (utxo.blockHeight == 0) + ? 0 + : Int64(finalWallet.currentBlockHeight) - utxo.blockHeight + 1 + + return UtxoWrapper(utxo: utxo, confirmationCount: confirmationCount) + } + + return wrappedUtxos + } + + func formattedBalances(_ sats: Bitcoin_kmpSatoshi) -> (FormattedAmount, FormattedAmount) { + + let btcAmt = Utils.formatBitcoin(currencyPrefs, sat: sats) + let fiatAmt = Utils.formatFiat(currencyPrefs, sat: sats) + + return (btcAmt, fiatAmt) + } + + func confirmationDialogBinding() -> Binding { + + return Binding( // SwiftUI only allows for 1 ".sheet" + get: { blockchainExplorerTxid != nil }, + set: { if !$0 { blockchainExplorerTxid = nil }} + ) + } + + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + func finalWalletChanged(_ newValue: Lightning_kmpWalletState.WalletWithConfirmations) { + log.trace("finalWalletChanged()") + + finalWallet = newValue + } + + // -------------------------------------------------- + // MARK: Actions + // -------------------------------------------------- + + func exploreTx(_ txid: String, website: BlockchainExplorer.Website) { + log.trace("exploreTX()") + + let txUrlStr = Biz.business.blockchainExplorer.txUrl(txId: txid, website: website) + if let txUrl = URL(string: txUrlStr) { + UIApplication.shared.open(txUrl) + } + } + + func copyTxId(_ txid: String) { + log.trace("copyTxId()") + + UIPasteboard.general.string = txid + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/SwapInWalletDetails.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/SwapInWalletDetails.swift new file mode 100644 index 000000000..9279dfdb9 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/SwapInWalletDetails.swift @@ -0,0 +1,361 @@ +import SwiftUI +import PhoenixShared +import os.log + +#if DEBUG && true +fileprivate var log = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: "SwapInWalletDetails" +) +#else +fileprivate var log = Logger(OSLog.disabled) +#endif + +struct SwapInWalletDetails: View { + + enum ViewLocation { + case popover + case embedded + } + + let location: ViewLocation + let popTo: (PopToDestination) -> Void + + @State var liquidityPolicy: LiquidityPolicy = Prefs.shared.liquidityPolicy + + @State var swapInWallet = Biz.business.balanceManager.swapInWalletValue() + let swapInWalletPublisher = Biz.business.balanceManager.swapInWalletPublisher() + + @State var blockchainExplorerTxid: String? = nil + + enum NavBarButtonWidth: Preference {} + let navBarButtonWidthReader = GeometryPreferenceReader( + key: AppendValue.self, + value: { [$0.size.width] } + ) + @State var navBarButtonWidth: CGFloat? = nil + + enum IconWidth: Preference {} + let iconWidthReader = GeometryPreferenceReader( + key: AppendValue.self, + value: { [$0.size.width] } + ) + @State var iconWidth: CGFloat? = nil + + @Environment(\.presentationMode) var presentationMode: Binding + + @EnvironmentObject var popoverState: PopoverState + @EnvironmentObject var currencyPrefs: CurrencyPrefs + @EnvironmentObject var deepLinkManager: DeepLinkManager + + // -------------------------------------------------- + // MARK: View Builders + // -------------------------------------------------- + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.center, spacing: 0) { + header() + content() + } + .navigationTitle(NSLocalizedString("Swap-in wallet", comment: "Navigation Bar Title")) + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + func header() -> some View { + + if location == .popover { + HStack(alignment: VerticalAlignment.center, spacing: 0) { + + Image(systemName: "xmark") + .imageScale(.medium) + .font(.title3) + .foregroundColor(.clear) + .accessibilityHidden(true) + .frame(width: navBarButtonWidth) + + Spacer(minLength: 0) + Text("Swap-in wallet") + .font(.headline) + .fontWeight(.medium) + .lineLimit(1) + Spacer(minLength: 0) + + Button { + closePopover() + } label: { + Image(systemName: "xmark") // must match size of chevron.backward above + .imageScale(.medium) + .font(.title3) + } + .read(navBarButtonWidthReader) + .frame(width: navBarButtonWidth) + + } // + .padding() + .assignMaxPreference(for: navBarButtonWidthReader.key, to: $navBarButtonWidth) + } + } + + @ViewBuilder + func content() -> some View { + + List { + section_info() + section_confirmed() + section_unconfirmed() + } + .listStyle(.insetGrouped) + .listBackgroundColor(.primaryBackground) + .onReceive(Prefs.shared.liquidityPolicyPublisher) { + liquidityPolicyChanged($0) + } + .onReceive(swapInWalletPublisher) { + swapInWalletChanged($0) + } + } + + @ViewBuilder + func section_info() -> some View { + + Section { + + VStack(alignment: HorizontalAlignment.leading, spacing: 20) { + + let maxFee = maxSwapInFee() + Text(styled: NSLocalizedString( + """ + On-chain funds will automatically be swapped to Lightning if the \ + fee is **less than \(maxFee.string)**. + """, + comment: "Swap-in wallet details" + )) + + Button { + navigateToLiquiditySettings() + } label: { + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 5) { + Image(systemName: "gearshape.fill") + .frame(minWidth: iconWidth, alignment: Alignment.leadingFirstTextBaseline) + .read(iconWidthReader) + Text("Configure fee settings") + } + } + + } // + .padding(.bottom, 5) + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 5) { + Image(systemName: "pipe.and.drop") + .frame(minWidth: iconWidth, alignment: Alignment.leadingFirstTextBaseline) + .read(iconWidthReader) + Text("Swaps are only attempted at application startup.") + } + .font(.subheadline) + .foregroundColor(Color(UIColor.systemOrange)) + .padding(.top, 5) + + } // + .assignMaxPreference(for: iconWidthReader.key, to: $iconWidth) + } + + @ViewBuilder + func section_confirmed() -> some View { + + Section { + + let confirmed = confirmedBalance() + Text(verbatim: "\(confirmed.0.string)") + + Text(verbatim: " โ‰ˆ \(confirmed.1.string)").foregroundColor(.secondary) + + } header: { + Text("Ready For Swap") + + } // + } + + @ViewBuilder + func section_unconfirmed() -> some View { + + Section { + + let utxos = unconfirmedUtxos() + if utxos.isEmpty { + + Text("No pending transactions") + .foregroundColor(.secondary) + + } else { + + ForEach(utxos) { utxo in + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + + Text(verbatim: "\(utxo.confirmationCount) / 3") + .monospacedDigit() + .foregroundColor(.secondary) + .padding(.trailing, 15) + + Group { + let (btcAmt, fiatAmt) = formattedBalances(utxo.amount) + + Text(verbatim: "\(btcAmt.string) ") + + Text(verbatim: " โ‰ˆ \(fiatAmt.string)").foregroundColor(.secondary) + } + .padding(.trailing, 15) + + Spacer(minLength: 0) + Button { + blockchainExplorerTxid = utxo.txid + } label: { + Image(systemName: "link") + } + } + } + } + + } header: { + Text("Waiting For 3 Confirmations") + + } // + .confirmationDialog("Blockchain Explorer", + isPresented: confirmationDialogBinding(), + titleVisibility: .automatic + ) { + let txid = blockchainExplorerTxid ?? "" + Button { + exploreTx(txid, website: BlockchainExplorer.WebsiteMempoolSpace()) + } label: { + Text(verbatim: "Mempool.space") // no localization needed + .textCase(.none) + } + Button { + exploreTx(txid, website: BlockchainExplorer.WebsiteBlockstreamInfo()) + } label: { + Text(verbatim: "Blockstream.info") // no localization needed + .textCase(.none) + } + Button { + copyTxId(txid) + } label: { + Text("Copy transaction id") + .textCase(.none) + } + } // + } + + // -------------------------------------------------- + // MARK: View Helpers + // -------------------------------------------------- + + func maxSwapInFee() -> FormattedAmount { + + let effectiveMax: Int64 + let absoluteMax: Int64 = liquidityPolicy.effectiveMaxFeeSats + + let swapInBalance: Int64 = swapInWallet.totalBalance.sat + if swapInBalance > 0 { + + let maxPercent: Double = Double(liquidityPolicy.effectiveMaxFeeBasisPoints) / Double(10_000) + let percentMax: Int64 = Int64(Double(swapInBalance) * maxPercent) + + effectiveMax = min(absoluteMax, percentMax) + + } else { + + effectiveMax = absoluteMax + } + + return Utils.formatBitcoin(currencyPrefs, sat: effectiveMax) + } + + func confirmedBalance() -> (FormattedAmount, FormattedAmount) { + + let confirmed = swapInWallet.deeplyConfirmedBalance + + let btcAmt = Utils.formatBitcoin(currencyPrefs, sat: confirmed) + let fiatAmt = Utils.formatFiat(currencyPrefs, sat: confirmed) + + return (btcAmt, fiatAmt) + } + + func unconfirmedUtxos() -> [UtxoWrapper] { + + let utxos = swapInWallet.weaklyConfirmed + swapInWallet.unconfirmed + let wrappedUtxos = utxos.map { utxo in + + let confirmationCount = (utxo.blockHeight == 0) + ? 0 + : Int64(swapInWallet.currentBlockHeight) - utxo.blockHeight + 1 + + return UtxoWrapper(utxo: utxo, confirmationCount: confirmationCount) + } + + return wrappedUtxos + } + + func formattedBalances(_ sats: Bitcoin_kmpSatoshi) -> (FormattedAmount, FormattedAmount) { + + let btcAmt = Utils.formatBitcoin(currencyPrefs, sat: sats) + let fiatAmt = Utils.formatFiat(currencyPrefs, sat: sats) + + return (btcAmt, fiatAmt) + } + + func confirmationDialogBinding() -> Binding { + + return Binding( // SwiftUI only allows for 1 ".sheet" + get: { blockchainExplorerTxid != nil }, + set: { if !$0 { blockchainExplorerTxid = nil }} + ) + } + + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + func liquidityPolicyChanged(_ newValue: LiquidityPolicy) { + log.trace("liquidityPolicyChanged()") + + self.liquidityPolicy = newValue + } + + func swapInWalletChanged(_ newValue: Lightning_kmpWalletState.WalletWithConfirmations) { + log.trace("swapInWalletChanged()") + + swapInWallet = newValue + } + + // -------------------------------------------------- + // MARK: Actions + // -------------------------------------------------- + + func exploreTx(_ txid: String, website: BlockchainExplorer.Website) { + log.trace("exploreTX()") + + let txUrlStr = Biz.business.blockchainExplorer.txUrl(txId: txid, website: website) + if let txUrl = URL(string: txUrlStr) { + UIApplication.shared.open(txUrl) + } + } + + func copyTxId(_ txid: String) { + log.trace("copyTxId()") + + UIPasteboard.general.string = txid + } + + func navigateToLiquiditySettings() { + log.trace("navigateToLiquiditySettings()") + + popTo(.ConfigurationView(followedBy: .liquiditySettings)) + presentationMode.wrappedValue.dismiss() + } + + func closePopover() { + log.trace("closePopover") + + popoverState.close() + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/UtxoWrapper.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/UtxoWrapper.swift new file mode 100644 index 000000000..4f156480b --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/UtxoWrapper.swift @@ -0,0 +1,19 @@ +import Swift +import PhoenixShared + +struct UtxoWrapper: Identifiable { + let utxo: Lightning_kmpWalletState.Utxo + let confirmationCount: Int64 + + var amount: Bitcoin_kmpSatoshi { + return utxo.amount + } + + var txid: String { + return utxo.previousTx.txid.toHex() + } + + var id: String { + return utxo.id + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/WalletInfoView.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/WalletInfoView.swift new file mode 100644 index 000000000..15cbc512d --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/WalletInfoView.swift @@ -0,0 +1,516 @@ +import SwiftUI +import PhoenixShared +import Popovers +import os.log + +#if DEBUG && true +fileprivate var log = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: "WalletInfoView" +) +#else +fileprivate var log = Logger(OSLog.disabled) +#endif + + +struct WalletInfoView: View { + + let popTo: (PopToDestination) -> Void + + @State var didAppear = false + @State var popToDestination: PopToDestination? = nil + + @State var popoverPresent_swapInWallet = false + @State var popoverPresent_finalWallet = false + + @State var final_mpk_truncationDetected = false + + @State var swapInWallet = Biz.business.balanceManager.swapInWalletValue() + let swapInWalletPublisher = Biz.business.balanceManager.swapInWalletPublisher() + + @State var finalWallet = Biz.business.peerManager.finalWalletValue() + let finalWalletPublisher = Biz.business.peerManager.finalWalletPublisher() + + @StateObject var toast = Toast() + + @Environment(\.colorScheme) var colorScheme: ColorScheme + @Environment(\.presentationMode) var presentationMode: Binding + + @EnvironmentObject var currencyPrefs: CurrencyPrefs + + // -------------------------------------------------- + // MARK: View Builders + // -------------------------------------------------- + + @ViewBuilder + var body: some View { + + ZStack { + content() + toast.view() + } + .navigationTitle(NSLocalizedString("Wallet info", comment: "Navigation Bar Title")) + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + func content() -> some View { + + List { + section_lightning() + section_swapInWallet() + section_finalWallet() + } + .listStyle(.insetGrouped) + .listBackgroundColor(.primaryBackground) + .onAppear() { + onAppear() + } + .onReceive(swapInWalletPublisher) { + swapInWalletChanged($0) + } + .onReceive(finalWalletPublisher) { + finalWalletChanged($0) + } + } + + @ViewBuilder + func section_lightning() -> some View { + + Section { + + VStack(alignment: HorizontalAlignment.leading, spacing: 10) { + + let nodeId = Biz.nodeId ?? "?" + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Node id").font(.headline.bold()) + Spacer() + copyButton(nodeId) + } + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text(verbatim: nodeId) + .font(.callout.weight(.light)) + .foregroundColor(.secondary) + Spacer(minLength: 0) + invisibleImage() + } + + } // + + } /*Section.*/header: { + + Text("Lightning") + + } // + } + + // -------------------------------------------------- + // MARK: View Builders: SwapInWallet + // -------------------------------------------------- + + @ViewBuilder + func section_swapInWallet() -> some View { + + Section { + + NavigationLink(destination: SwapInWalletDetails(location: .embedded, popTo: popToWrapper)) { + subsection_swapInWallet_balance() + } + subsection_swapInWallet_descriptor() + + } header: { + subsection_swapInWallet_header() + + } // + } + + @ViewBuilder + func subsection_swapInWallet_header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Swap-in wallet") + Spacer() + Button { + popoverPresent_swapInWallet = true + } label: { + Image(systemName: "info.circle") + } + .foregroundColor(.secondary) + .popover(present: $popoverPresent_swapInWallet) { + InfoPopoverWindow { + Text( + """ + An on-chain wallet derived from your seed. + + The swap-in wallet is a bridge to Lightning. \ + Funds on this wallet will automatically be moved to Lightning \ + according to your liquidity policy setting. + """ + ) + } + } + } // + } + + @ViewBuilder + func subsection_swapInWallet_balance() -> some View { + + balances( + confirmed: swapInBalance_confirmed(), + unconfirmed: swapInBalance_unconfirmed() + ) + } + + @ViewBuilder + func subsection_swapInWallet_descriptor() -> some View { + + let keyManager = Biz.business.walletManager.getKeyManager() + let descriptor = keyManager?.swapInOnChainWallet.descriptor ?? "?" + + VStack(alignment: HorizontalAlignment.leading, spacing: 10) { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Descriptor") + .font(.headline.bold()) + Spacer() + copyButton(descriptor) + } + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text(descriptor) + .lineLimit(2) + .font(.callout.weight(.light)) + .foregroundColor(.secondary) + Spacer(minLength: 0) + invisibleImage() + } + + } // + } + + // -------------------------------------------------- + // MARK: View Builders: FinalWallet + // -------------------------------------------------- + + @ViewBuilder + func section_finalWallet() -> some View { + + Section { + + NavigationLink(destination: FinalWalletDetails()) { + subsection_finalWallet_balance() + } + subsection_finalWallet_masterPublicKey() + + } header: { + subsection_finalWallet_header() + + } // + } + + @ViewBuilder + func subsection_finalWallet_header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Final wallet") + Spacer() + Button { + popoverPresent_finalWallet = true + } label: { + Image(systemName: "info.circle") + } + .foregroundColor(.secondary) + .popover(present: $popoverPresent_finalWallet) { + InfoPopoverWindow { + Text( + """ + An on-chain wallet derived from your seed. + + The final wallet is where funds are sent by default when \ + your lightning channels are closed. + """ + ) + } + } + } // + } + + @ViewBuilder + func subsection_finalWallet_balance() -> some View { + + balances( + confirmed: finalBalance_confirmed(), + unconfirmed: finalBalance_unconfirmed() + ) + } + + @ViewBuilder + func subsection_finalWallet_masterPublicKey() -> some View { + + let keyManager = Biz.business.walletManager.getKeyManager() + let keyPath = keyManager?.finalOnChainWalletPath ?? "?" + let xpub = keyManager?.finalOnChainWallet.xpub ?? "?" + + VStack(alignment: HorizontalAlignment.leading, spacing: 10) { + + if !final_mpk_truncationDetected { + + // All on one line: + // Master public key (Path m/x/y/z) + + TruncatableView(fixedHorizontal: true, fixedVertical: true) { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Master public key") + .font(.headline.bold()) + Text(" (Path \(keyPath))") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + copyButton(xpub) + } // + + } wasTruncated: { + final_mpk_truncationDetected = true + + } // + + } else /* if truncationDetected */ { + + // Too big to fit on one line => switch to two lines: + // Master public key + // Path: m/x/y/z + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Master public key") + .font(.headline.bold()) + Spacer() + copyButton(xpub) + } + + Text("Path: \(keyPath)") + .font(.callout) + .foregroundColor(.secondary) + + } // + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text(xpub) + .lineLimit(2) + .font(.callout.weight(.light)) + .foregroundColor(.secondary) + Spacer(minLength: 0) + invisibleImage() + } + + } // + } + + // -------------------------------------------------- + // MARK: View Builders: Utils + // -------------------------------------------------- + + @ViewBuilder + func balances( + confirmed : Bitcoin_kmpSatoshi, + unconfirmed : Bitcoin_kmpSatoshi + ) -> some View { + + let hasPositiveConfirmed = confirmed.sat > 0 + let hasPositiveUnconfirmed = unconfirmed.sat > 0 + + if hasPositiveConfirmed && hasPositiveUnconfirmed { + + /* This looks a bit crowded... + I think it looks cleaner if we simply display the total in this scenario. + If the user wants more information, there's the SwapInWalletDetails screen. + + if #available(iOS 16, *) { + Grid(horizontalSpacing: 8, verticalSpacing: 12) { + GridRow(alignment: VerticalAlignment.firstTextBaseline) { + Text("confirmed") + .textCase(.lowercase) + .font(.subheadline) + .foregroundColor(.secondary) + .gridColumnAlignment(.trailing) + + Text(verbatim: confirmed.0.string) + + Text(verbatim: " โ‰ˆ \(confirmed.1.string)").foregroundColor(.secondary) + } + GridRow(alignment: VerticalAlignment.firstTextBaseline) { + Text("unconfirmed") + .textCase(.lowercase) + .font(.subheadline) + .foregroundColor(.secondary) + .gridColumnAlignment(.trailing) + + Text(verbatim: unconfirmed.0.string) + + Text(verbatim: " โ‰ˆ \(unconfirmed.1.string)").foregroundColor(.secondary) + } + } // + } else { + VStack(alignment: HorizontalAlignment.leading, spacing: 12) { + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 8) { + Text("confirmed") + .textCase(.lowercase) + .font(.subheadline) + .foregroundColor(.secondary) + + Text(verbatim: confirmed.0.string) + + Text(verbatim: " โ‰ˆ \(confirmed.1.string)").foregroundColor(.secondary) + } + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 8) { + Text("unconfirmed") + .textCase(.lowercase) + .font(.subheadline) + .foregroundColor(.secondary) + + Text(verbatim: unconfirmed.0.string) + + Text(verbatim: " โ‰ˆ \(unconfirmed.1.string)").foregroundColor(.secondary) + } + } // + } + */ + + let total = confirmed.sat + unconfirmed.sat + let (btcAmt, fiatAmt) = formattedBalances(total) + + Text(verbatim: "\(btcAmt.string) ") + + Text(verbatim: " โ‰ˆ \(fiatAmt.string)").foregroundColor(.secondary) + + } else if hasPositiveUnconfirmed { + + let (btcAmt, fiatAmt) = formattedBalances(unconfirmed) + + Text(verbatim: "\(btcAmt.string) ") + + Text(verbatim: " โ‰ˆ \(fiatAmt.string)").foregroundColor(.secondary) + + } else { + + let (btcAmt, fiatAmt) = formattedBalances(confirmed) + + Text(verbatim: btcAmt.string) + + Text(verbatim: " โ‰ˆ \(fiatAmt.string)").foregroundColor(.secondary) + } + } + + @ViewBuilder + func copyButton(_ str: String) -> some View { + + Button { + copyToPasteboard(str) + } label: { + Image(systemName: "square.on.square") + } + .buttonStyle(BorderlessButtonStyle()) // prevents trigger when row tapped + } + + @ViewBuilder + func invisibleImage() -> some View { + + Image(systemName: "square.on.square") + .foregroundColor(.clear) + .accessibilityHidden(true) + } + + // -------------------------------------------------- + // MARK: View Helpers + // -------------------------------------------------- + + func swapInBalance_confirmed() -> Bitcoin_kmpSatoshi { + + return swapInWallet.deeplyConfirmedBalance + } + + func swapInBalance_unconfirmed() -> Bitcoin_kmpSatoshi { + + // In the context of the swap-in wallet, any tx that is weakly confirmed (< 3 confirmations), + // is still considered "pending" for the purposes of swapping it to lightning. + + let sats = swapInWallet.weaklyConfirmedBalance.sat + swapInWallet.unconfirmedBalance.sat + return Bitcoin_kmpSatoshi(sat: sats) + } + + func finalBalance_confirmed() -> Bitcoin_kmpSatoshi { + + return finalWallet.anyConfirmedBalance + } + + func finalBalance_unconfirmed() -> Bitcoin_kmpSatoshi { + + return finalWallet.unconfirmedBalance + } + + func formattedBalances(_ sats: Bitcoin_kmpSatoshi) -> (FormattedAmount, FormattedAmount) { + + return formattedBalances(sats.toLong()) + } + + func formattedBalances(_ sats: Int64) -> (FormattedAmount, FormattedAmount) { + + let btcAmt = Utils.formatBitcoin(currencyPrefs, sat: sats) + let fiatAmt = Utils.formatFiat(currencyPrefs, sat: sats) + + return (btcAmt, fiatAmt) + } + + func popToWrapper(_ destination: PopToDestination) { + log.trace("popToWrapper(\(destination))") + + popToDestination = destination + popTo(destination) + } + + // -------------------------------------------------- + // MARK: View Lifecycle + // -------------------------------------------------- + + func onAppear(){ + log.trace("onAppear()") + + if !didAppear { + didAppear = true + + } else { + + if let destination = popToDestination { + log.debug("popToDestination: \(destination)") + + popToDestination = nil + presentationMode.wrappedValue.dismiss() + } + } + } + + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + func swapInWalletChanged(_ newValue: Lightning_kmpWalletState.WalletWithConfirmations) { + log.trace("swapInWalletChanged()") + + swapInWallet = newValue + } + + func finalWalletChanged(_ newValue: Lightning_kmpWalletState.WalletWithConfirmations) { + log.trace("finalWalletChanged()") + + finalWallet = newValue + } + + // -------------------------------------------------- + // MARK: Actions + // -------------------------------------------------- + + func copyToPasteboard(_ str: String) { + log.trace("copyToPasteboard()") + + UIPasteboard.general.string = str + toast.pop( + NSLocalizedString("Copied to pasteboard!", comment: "Toast message"), + colorScheme: colorScheme.opposite + ) + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/channels/ForceCloseChannelsView.swift b/phoenix-ios/phoenix-ios/views/configuration/danger zone/ForceCloseChannelsView.swift similarity index 98% rename from phoenix-ios/phoenix-ios/views/configuration/advanced/channels/ForceCloseChannelsView.swift rename to phoenix-ios/phoenix-ios/views/configuration/danger zone/ForceCloseChannelsView.swift index 21e02ef82..d4e5fca89 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/advanced/channels/ForceCloseChannelsView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/danger zone/ForceCloseChannelsView.swift @@ -19,7 +19,7 @@ struct ForceCloseChannelsView : MVIView { @Environment(\.controllerFactory) var factoryEnv var factory: ControllerFactory { return factoryEnv } - @Environment(\.popoverState) var popoverState: PopoverState + @EnvironmentObject var popoverState: PopoverState // -------------------------------------------------- // MARK: ViewBuilders @@ -295,7 +295,7 @@ fileprivate struct ConfirmationPopover : View { let confirmAction: () -> Void - @Environment(\.popoverState) var popoverState: PopoverState + @EnvironmentObject var popoverState: PopoverState var body: some View { diff --git a/phoenix-ios/phoenix-ios/views/configuration/general/drain wallet/DrainWalletView.swift b/phoenix-ios/phoenix-ios/views/configuration/danger zone/drain wallet/DrainWalletView.swift similarity index 93% rename from phoenix-ios/phoenix-ios/views/configuration/general/drain wallet/DrainWalletView.swift rename to phoenix-ios/phoenix-ios/views/configuration/danger zone/drain wallet/DrainWalletView.swift index 71940184d..cefee33c2 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/general/drain wallet/DrainWalletView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/danger zone/drain wallet/DrainWalletView.swift @@ -19,11 +19,11 @@ struct DrainWalletView: MVIView { @Environment(\.controllerFactory) var factoryEnv var factory: ControllerFactory { return factoryEnv } - let popToRoot: () -> Void + let popTo: (PopToDestination) -> Void let encryptedNodeId = Biz.encryptedNodeId! @State var didAppear = false - @State var popToRootRequested = false + @State var popToDestination: PopToDestination? = nil @State var textFieldValue: String = "" @State var scannedValue: String? = nil @@ -86,6 +86,9 @@ struct DrainWalletView: MVIView { // section_options() section_button() + + } else if mvi.model is CloseChannelsConfiguration.ModelChannelsClosed { + section_channelsClosed() } } .listStyle(.insetGrouped) @@ -104,6 +107,17 @@ struct DrainWalletView: MVIView { } // } + @ViewBuilder + func section_channelsClosed() -> some View { + + Section { + HStack(alignment: VerticalAlignment.center, spacing: 8) { + Image(systemName: "checkmark.circle.fill").foregroundColor(.appPositive) + Text("Channels closed") + } + } // + } + @ViewBuilder func section_balance() -> some View { @@ -239,7 +253,7 @@ struct DrainWalletView: MVIView { DrainWalletView_Confirm( mvi: mvi, bitcoinAddress: bitcoinAddress, - popToRoot: updatedPopToRoot + popTo: popToWrapper ) } } @@ -261,11 +275,11 @@ struct DrainWalletView: MVIView { return (balance_bitcoin, balance_fiat) } - func updatedPopToRoot() { - log.trace("updatedPopToRoot()") + func popToWrapper(_ destination: PopToDestination) { + log.trace("popToWrapper(\(destination))") - popToRootRequested = true - popToRoot() + popToDestination = destination + popTo(destination) } // -------------------------------------------------- @@ -287,8 +301,8 @@ struct DrainWalletView: MVIView { } else { - if popToRootRequested { - popToRootRequested = false + if popToDestination != nil { + popToDestination = nil presentationMode.wrappedValue.dismiss() } } @@ -328,11 +342,10 @@ struct DrainWalletView: MVIView { if let error = error as? BitcoinAddressError.ChainMismatch { detailedErrorMsg = String(format: NSLocalizedString( """ - The address is for %@, \ - but you're on %@ + The address is not for %@ """, comment: "Error message - parsing bitcoin address"), - error.actual.name, error.expected.name + error.expected.name ) } else if isScannedValue { diff --git a/phoenix-ios/phoenix-ios/views/configuration/general/drain wallet/DrainWalletView_Action.swift b/phoenix-ios/phoenix-ios/views/configuration/danger zone/drain wallet/DrainWalletView_Action.swift similarity index 98% rename from phoenix-ios/phoenix-ios/views/configuration/general/drain wallet/DrainWalletView_Action.swift rename to phoenix-ios/phoenix-ios/views/configuration/danger zone/drain wallet/DrainWalletView_Action.swift index c313397fa..c4298c0bc 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/general/drain wallet/DrainWalletView_Action.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/danger zone/drain wallet/DrainWalletView_Action.swift @@ -15,7 +15,7 @@ struct DrainWalletView_Action: MVISubView { @ObservedObject var mvi: MVIState let expectedTxCount: Int - let popToRoot: () -> Void + let popTo: (PopToDestination) -> Void @Environment(\.presentationMode) var presentationMode: Binding @@ -169,7 +169,7 @@ struct DrainWalletView_Action: MVISubView { func doneButtonTapped() { log.trace("doneButtonTapped()") + popTo(.RootView(followedBy: nil)) presentationMode.wrappedValue.dismiss() - popToRoot() } } diff --git a/phoenix-ios/phoenix-ios/views/configuration/general/drain wallet/DrainWalletView_Confirm.swift b/phoenix-ios/phoenix-ios/views/configuration/danger zone/drain wallet/DrainWalletView_Confirm.swift similarity index 93% rename from phoenix-ios/phoenix-ios/views/configuration/general/drain wallet/DrainWalletView_Confirm.swift rename to phoenix-ios/phoenix-ios/views/configuration/danger zone/drain wallet/DrainWalletView_Confirm.swift index 79a76577d..925719dfe 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/general/drain wallet/DrainWalletView_Confirm.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/danger zone/drain wallet/DrainWalletView_Confirm.swift @@ -15,12 +15,12 @@ struct DrainWalletView_Confirm: MVISubView { @ObservedObject var mvi: MVIState let bitcoinAddress: String - let popToRoot: () -> Void + let popTo: (PopToDestination) -> Void @State var actionRequested: Bool = false @State var expectedTxCount: Int = 0 - @State var popToRootRequested = false + @State var popToDestination: PopToDestination? = nil @Environment(\.presentationMode) var presentationMode: Binding @@ -155,7 +155,7 @@ struct DrainWalletView_Confirm: MVISubView { DrainWalletView_Action( mvi: mvi, expectedTxCount: expectedTxCount, - popToRoot: updatedPopToRoot + popTo: popToWrapper ) } @@ -195,11 +195,11 @@ struct DrainWalletView_Confirm: MVISubView { } } - func updatedPopToRoot() { - log.trace("updatedPopToRoot()") + func popToWrapper(_ destination: PopToDestination) { + log.trace("popToWrapper(\(destination))") - popToRootRequested = true - popToRoot() + popToDestination = destination + popTo(destination) } // -------------------------------------------------- @@ -209,8 +209,8 @@ struct DrainWalletView_Confirm: MVISubView { func onAppear() { log.trace("onAppear()") - if popToRootRequested { - popToRootRequested = false + if popToDestination != nil { + popToDestination = nil presentationMode.wrappedValue.dismiss() } } diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/reset wallet/ResetWalletView.swift b/phoenix-ios/phoenix-ios/views/configuration/danger zone/reset wallet/ResetWalletView.swift similarity index 100% rename from phoenix-ios/phoenix-ios/views/configuration/advanced/reset wallet/ResetWalletView.swift rename to phoenix-ios/phoenix-ios/views/configuration/danger zone/reset wallet/ResetWalletView.swift diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/reset wallet/ResetWalletView_Action.swift b/phoenix-ios/phoenix-ios/views/configuration/danger zone/reset wallet/ResetWalletView_Action.swift similarity index 100% rename from phoenix-ios/phoenix-ios/views/configuration/advanced/reset wallet/ResetWalletView_Action.swift rename to phoenix-ios/phoenix-ios/views/configuration/danger zone/reset wallet/ResetWalletView_Action.swift diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/reset wallet/ResetWalletView_Confirm.swift b/phoenix-ios/phoenix-ios/views/configuration/danger zone/reset wallet/ResetWalletView_Confirm.swift similarity index 100% rename from phoenix-ios/phoenix-ios/views/configuration/advanced/reset wallet/ResetWalletView_Confirm.swift rename to phoenix-ios/phoenix-ios/views/configuration/danger zone/reset wallet/ResetWalletView_Confirm.swift diff --git a/phoenix-ios/phoenix-ios/views/configuration/general/WalletInfoView.swift b/phoenix-ios/phoenix-ios/views/configuration/general/WalletInfoView.swift deleted file mode 100644 index e9eb746fc..000000000 --- a/phoenix-ios/phoenix-ios/views/configuration/general/WalletInfoView.swift +++ /dev/null @@ -1,334 +0,0 @@ -import SwiftUI -import PhoenixShared -import Popovers -import os.log - -#if DEBUG && true -fileprivate var log = Logger( - subsystem: Bundle.main.bundleIdentifier!, - category: "WalletInfoView" -) -#else -fileprivate var log = Logger(OSLog.disabled) -#endif - - -struct WalletInfoView: View { - - @State var popoverPresent_swapInWallet = false - @State var popoverPresent_finalWallet = false - - @State var swapIn_mpk_truncationDetected = false - @State var final_mpk_truncationDetected = false - - @StateObject var toast = Toast() - - @Environment(\.colorScheme) var colorScheme: ColorScheme - - @EnvironmentObject var currencyPrefs: CurrencyPrefs - - // -------------------------------------------------- - // MARK: View Builders - // -------------------------------------------------- - - @ViewBuilder - var body: some View { - - ZStack { - content() - toast.view() - } - .navigationTitle(NSLocalizedString("Wallet info", comment: "Navigation Bar Title")) - .navigationBarTitleDisplayMode(.inline) - } - - @ViewBuilder - func content() -> some View { - - List { - section_lightning() - section_swapInWallet() - section_finalWallet() - } - .listStyle(.insetGrouped) - .listBackgroundColor(.primaryBackground) - } - - @ViewBuilder - func section_lightning() -> some View { - - Section { - - VStack(alignment: HorizontalAlignment.leading, spacing: 10) { - - let nodeId = Biz.nodeId ?? "?" - - HStack(alignment: VerticalAlignment.center, spacing: 0) { - Text("Node id").font(.headline.bold()) - Spacer() - copyButton(nodeId) - } - - HStack(alignment: VerticalAlignment.center, spacing: 0) { - Text(verbatim: nodeId) - .font(.callout.weight(.light)) - .foregroundColor(.secondary) - Spacer(minLength: 0) - invisibleImage() - } - - } // - - } /*Section.*/header: { - - Text("Lightning") - - } // - } - - @ViewBuilder - func section_swapInWallet() -> some View { - - Section { - - VStack(alignment: HorizontalAlignment.leading, spacing: 10) { - - let balances_confirmed = swapInBalance_confirmed() - - Text(balances_confirmed.0.string) + - Text(verbatim: " โ‰ˆ \(balances_confirmed.1.string)").foregroundColor(.secondary) - - if let balances_unconfirmed = swapInBalance_unconfirmed() { - - Text("+ \(balances_unconfirmed.0.string) unconfirmed") + - Text(verbatim: " โ‰ˆ \(balances_unconfirmed.1.string)").foregroundColor(.secondary) - } - } // - - // let swapInWallet = Biz.business.walletManager.getKeyManager()?.swapInOnChainWallet - masterPublicKey( - keyPath: "?", // swapInOnChainWallet?.path ?? "?", - xpub: "?", // swapInOnChainWallet?.xpub ?? "?", - truncationDetected: $swapIn_mpk_truncationDetected - ) - - } /*Section.*/header: { - - HStack(alignment: VerticalAlignment.center, spacing: 0) { - Text("Swap-in wallet") - Spacer() - Button { - popoverPresent_swapInWallet = true - } label: { - Image(systemName: "info.circle") - } - .foregroundColor(.secondary) - .popover(present: $popoverPresent_swapInWallet) { - InfoPopoverWindow { - Text( - """ - An on-chain wallet derived from your seed. - - The swap-in wallet is a bridge to Lightning. \ - Funds on this wallet will automatically be moved to Lightning \ - according to your liquidity policy setting. - """ - ) - } - } - } // - - } // - } - - @ViewBuilder - func section_finalWallet() -> some View { - - Section { - - VStack(alignment: HorizontalAlignment.leading, spacing: 10) { - - let balances_confirmed = finalBalance_confirmed() - - Text(balances_confirmed.0.string) + - Text(verbatim: " โ‰ˆ \(balances_confirmed.1.string)").foregroundColor(.secondary) - - if let balances_unconfirmed = finalBalance_unconfirmed() { - - Text("+ \(balances_unconfirmed.0.string) unconfirmed") + - Text(verbatim: " โ‰ˆ \(balances_unconfirmed.1.string)").foregroundColor(.secondary) - } - } // - - let keyManager = Biz.business.walletManager.getKeyManager() - masterPublicKey( - keyPath: keyManager?.finalOnChainWalletPath ?? "?", - xpub: keyManager?.finalOnChainWallet.xpub ?? "?", - truncationDetected: $final_mpk_truncationDetected - ) - - } /*Section.*/header: { - - HStack(alignment: VerticalAlignment.center, spacing: 0) { - Text("Final wallet") - Spacer() - Button { - popoverPresent_finalWallet = true - } label: { - Image(systemName: "info.circle") - } - .foregroundColor(.secondary) - .popover(present: $popoverPresent_finalWallet) { - InfoPopoverWindow { - Text( - """ - An on-chain wallet derived from your seed. - - The final wallet is where funds are sent by default when \ - your lightning channels are closed. - """ - ) - } - } - } // - } - } - - @ViewBuilder - func masterPublicKey( - keyPath: String, - xpub: String, - truncationDetected: Binding - ) -> some View { - - VStack(alignment: HorizontalAlignment.leading, spacing: 10) { - - if !truncationDetected.wrappedValue { - - // All on one line: - // Master public key (Path m/x/y/z) - - TruncatableView(fixedHorizontal: true, fixedVertical: true) { - - HStack(alignment: VerticalAlignment.center, spacing: 0) { - Text("Master public key") - .font(.headline.bold()) - Text(" (Path \(keyPath))") - .font(.subheadline) - .foregroundColor(.secondary) - Spacer() - copyButton(xpub) - } // - - } wasTruncated: { - truncationDetected.wrappedValue = true - - } // - - } else /* if truncationDetected */ { - - // Too big to fit on one line => switch to two lines: - // Master public key - // Path: m/x/y/z - - HStack(alignment: VerticalAlignment.center, spacing: 0) { - Text("Master public key") - .font(.headline.bold()) - Spacer() - copyButton(xpub) - } - - Text("Path: \(keyPath)") - .font(.callout) - .foregroundColor(.secondary) - - } // - - HStack(alignment: VerticalAlignment.center, spacing: 0) { - Text(xpub) - .font(.callout.weight(.light)) - .foregroundColor(.secondary) - Spacer(minLength: 0) - invisibleImage() - } - - } // - } - - @ViewBuilder - func copyButton(_ str: String) -> some View { - - Button { - copyToPasteboard(str) - } label: { - Image(systemName: "square.on.square") - } - .buttonStyle(BorderlessButtonStyle()) // prevents trigger when row tapped - } - - @ViewBuilder - func invisibleImage() -> some View { - - Image(systemName: "square.on.square") - .foregroundColor(.clear) - .accessibilityHidden(true) - } - - // -------------------------------------------------- - // MARK: View Helpers - // -------------------------------------------------- - - func swapInBalance_confirmed() -> (FormattedAmount, FormattedAmount) { - - let sats = Biz.business.balanceManager.swapInWalletBalanceValue().confirmed - return formattedBalances(sats) - } - - func swapInBalance_unconfirmed() -> (FormattedAmount, FormattedAmount)? { - - let sats = Biz.business.balanceManager.swapInWalletBalanceValue().unconfirmed - if sats.toLong() > 0 { - return formattedBalances(sats) - } else { - return nil - } - } - - func finalBalance_confirmed() -> (FormattedAmount, FormattedAmount) { - - let sats = Biz.business.peerManager.finalWalletBalance().confirmedBalance - return formattedBalances(sats) - } - - func finalBalance_unconfirmed() -> (FormattedAmount, FormattedAmount)? { - - let sats = Biz.business.peerManager.finalWalletBalance().unconfirmedBalance - if sats.toLong() > 0 { - return formattedBalances(sats) - } else { - return nil - } - } - - func formattedBalances(_ sats: Bitcoin_kmpSatoshi) -> (FormattedAmount, FormattedAmount) { - - let btcAmt = Utils.formatBitcoin(currencyPrefs, sat: sats) - let fiatAmt = Utils.formatFiat(currencyPrefs, sat: sats) - - return (btcAmt, fiatAmt) - } - - // -------------------------------------------------- - // MARK: Actions - // -------------------------------------------------- - - func copyToPasteboard(_ str: String) { - log.trace("copyToPasteboard()") - - UIPasteboard.general.string = str - toast.pop( - NSLocalizedString("Copied to pasteboard!", comment: "Toast message"), - colorScheme: colorScheme.opposite - ) - } -} diff --git a/phoenix-ios/phoenix-ios/views/configuration/general/payment options/LiquidityPolicyHelp.swift b/phoenix-ios/phoenix-ios/views/configuration/general/channel management/LiquidityPolicyHelp.swift similarity index 100% rename from phoenix-ios/phoenix-ios/views/configuration/general/payment options/LiquidityPolicyHelp.swift rename to phoenix-ios/phoenix-ios/views/configuration/general/channel management/LiquidityPolicyHelp.swift diff --git a/phoenix-ios/phoenix-ios/views/configuration/general/payment options/LiquidityPolicyView.swift b/phoenix-ios/phoenix-ios/views/configuration/general/channel management/LiquidityPolicyView.swift similarity index 79% rename from phoenix-ios/phoenix-ios/views/configuration/general/payment options/LiquidityPolicyView.swift rename to phoenix-ios/phoenix-ios/views/configuration/general/channel management/LiquidityPolicyView.swift index 0deeec3df..f4880857b 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/general/payment options/LiquidityPolicyView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/general/channel management/LiquidityPolicyView.swift @@ -29,6 +29,14 @@ struct LiquidityPolicyView: View { @State var lightningOverride = false + @State var mempoolRecommendedResponse: MempoolRecommendedResponse? = nil + + enum MaxFeeAmountError: Error { + case tooLow(sats: Int64) + case tooHigh(sats: Int64) + case invalidInput + } + enum MaxFeeAmtFiatHeight: Preference {} let maxFeeAmtFiatHeightReader = GeometryPreferenceReader( key: AppendValue.self, @@ -50,16 +58,22 @@ struct LiquidityPolicyView: View { let defaultLp = NodeParamsManager.companion.defaultLiquidityPolicy let userLp = Prefs.shared.liquidityPolicy - isEnabled = true + let __isEnabled = userLp.enabled let sats = userLp.maxFeeSats ?? defaultLp.maxAbsoluteFee.sat - maxFeeAmt = LiquidityPolicyView.formattedMaxFeeAmt(sat: sats) - parsedMaxFeeAmt = .success(NSNumber(value: sats)) + let __maxFeeAmt = LiquidityPolicyView.formattedMaxFeeAmt(sat: sats) + let __parsedMaxFeeAmt: Result = .success(NSNumber(value: sats)) let basisPoints = userLp.maxFeeBasisPoints ?? defaultLp.maxRelativeFeeBasisPoints let percent = Double(basisPoints) / Double(100) - maxFeePrcnt = LiquidityPolicyView.formattedMaxFeePrcnt(percent: percent) - parsedMaxFeePrcnt = .success(NSNumber(value: percent)) + let __maxFeePrcnt = LiquidityPolicyView.formattedMaxFeePrcnt(percent: percent) + let __parsedMaxFeePrcnt: Result = .success(NSNumber(value: percent)) + + self._isEnabled = State(initialValue: __isEnabled) + self._maxFeeAmt = State(initialValue: __maxFeeAmt) + self._parsedMaxFeeAmt = State(initialValue: __parsedMaxFeeAmt) + self._maxFeePrcnt = State(initialValue: __maxFeePrcnt) + self._parsedMaxFeePrcnt = State(initialValue: __parsedMaxFeePrcnt) } // -------------------------------------------------- @@ -80,6 +94,9 @@ struct LiquidityPolicyView: View { List { section_explanation() section_general() + if let mempoolRecommendedResponse { + section_estimate(mempoolRecommendedResponse) + } if isEnabled { if showAdvanced { section_percentageCheck() @@ -107,6 +124,9 @@ struct LiquidityPolicyView: View { .onDisappear { onDisappear() } + .task { + await fetchMempoolRecommendedFees() + } } // -------------------------------------------------- @@ -145,6 +165,28 @@ struct LiquidityPolicyView: View { } } + @ViewBuilder + func section_estimate(_ mrr: MempoolRecommendedResponse) -> some View { + + Section { + VStack(alignment: HorizontalAlignment.leading, spacing: 10) { + + let sat = mrr.swapEstimationFee(hasNoChannels: true) + let btcAmt = Utils.formatBitcoin(currencyPrefs, sat: sat) + let fiatAmt = Utils.formatFiat(currencyPrefs, sat: sat) + + Text(styled: String(format: NSLocalizedString( + "Fees are currently estimated at around **%@** (โ‰ˆ %@).", + comment: "Fee estimate" + ), btcAmt.string, fiatAmt.string)) + } + + } /* Section.*/header: { + + Text("Mempool") + } + } + @ViewBuilder func section_showAdvancedButton() -> some View { @@ -254,7 +296,11 @@ struct LiquidityPolicyView: View { .padding(.bottom, 8.0 + ((maxFeeAmtFiatHeight ?? 12.0) / 2.0)) .background( RoundedRectangle(cornerRadius: 8) - .stroke(maxFeeAmountHasError() ? Color.appNegative : Color.textFieldBorder, lineWidth: 1) + .stroke( + maxFeeAmountHasError() ? Color.appNegative : + maxFeeAmountHasWarning() ? Color.appWarn : Color.textFieldBorder, + lineWidth: 1 + ) ) .padding(.bottom, ((maxFeeAmtFiatHeight ?? 12.0) / 2.0)) .overlay { @@ -275,10 +321,33 @@ struct LiquidityPolicyView: View { } // - Text("Incoming payments whose fees exceed this value will be rejected.") - .font(.subheadline) - .foregroundColor(.secondary) - .padding(.top, 12) + Group { + switch maxFeeAmountSats() { + case .success(_): + if maxFeeAmountHasWarning() { + Text("Below the expected fee. Some payments may be rejected.") + .foregroundColor(.secondary) // because yellow text is too hard to read + } else { + Text("Incoming payments whose fees exceed this value will be rejected.") + .foregroundColor(.secondary) + } + + case .failure(let reason): + switch reason { + case .invalidInput: + Text("Please enter a valid amount.") + .foregroundColor(.appNegative) + case .tooLow(_): + Text("Amount is too low.") + .foregroundColor(.appNegative) + case .tooHigh(_): + Text("Amount is too high.") + .foregroundColor(.appNegative) + } + } + } + .font(.subheadline) + .padding(.top, 12) } // .padding(.top, 20) @@ -501,53 +570,78 @@ struct LiquidityPolicyView: View { return LiquidityPolicyView.formattedMaxFeeAmt(sat: sats) } - func maxFeeAmountSatsIsValid(_ sats: Int64) -> Bool { + func maxFeeAmountSats() -> Result { - return sats > 0 && sats <= 500_000 - } - - func maxFeeAmountSats() -> Int64? { - - if case .success(let number) = parsedMaxFeeAmt { + switch parsedMaxFeeAmt { + case .success(let number): let sats = number.int64Value - if maxFeeAmountSatsIsValid(sats) { - return sats + if sats < 0 { return .failure(.invalidInput) } + if sats < 150 { return .failure(.tooLow(sats: sats)) } + if sats > 500_000 { return .failure(.tooHigh(sats: sats)) } + else { return .success(sats) } + + case .failure(let reason): + switch reason { + case .emptyInput : return .success(defaultMaxFeeAmountSats()) + case .invalidInput : return .failure(.invalidInput) } } - - return nil } func maxFeeAmountHasError() -> Bool { - switch parsedMaxFeeAmt { - case .success(let number): - let sats = number.int64Value - return !maxFeeAmountSatsIsValid(sats) - - case .failure(let reason): - switch reason { - case .emptyInput : return false - case .invalidInput : return true - } + switch maxFeeAmountSats() { + case .success(_) : return false + case .failure(_) : return true + } + } + + func maxFeeAmountHasWarning() -> Bool { + + guard let mrr = mempoolRecommendedResponse else { + return false + } + + let minSat = mrr.swapEstimationFee(hasNoChannels: true).sat + + switch maxFeeAmountSats() { + case .success(let sats) : return sats < minSat + case .failure(_) : return false } } func maxFeeAmountFiat() -> FormattedAmount { - if let sats = maxFeeAmountSats() { + switch maxFeeAmountSats() { + case .success(let sats): return Utils.formatFiat(currencyPrefs, sat: sats) - } else { - return Utils.unknownFiatAmount(fiatCurrency: currencyPrefs.fiatCurrency) + + case .failure(let reason): + switch reason { + case .tooLow(let sats): + // I think it makes sense to display the fiat currency amount in this situation. + // Because we're forcing the user to enter an amount in sats... + // And they might not be familiar with the exchange rate, so they're kinda entering values blindly. + // If the amount is too low, it's good that they can see why (because the fiat value is tiny). + return Utils.formatFiat(currencyPrefs, sat: sats) + + case .tooHigh(_): + // We could display the fiat amount here too, but the danger is that the value may be huge. + // Which means the UI text could easily overflow. + // So we're going to display unknown amount instead. + return Utils.unknownFiatAmount(fiatCurrency: currencyPrefs.fiatCurrency) + + case .invalidInput: + return Utils.unknownFiatAmount(fiatCurrency: currencyPrefs.fiatCurrency) + } } } func effectiveMaxFeeAmountSats() -> Int64 { - if let sats = maxFeeAmountSats() { - return sats - } else { - return defaultMaxFeeAmountSats() + switch maxFeeAmountSats() { + case .success(let sats) : return sats + case .failure(_) : return defaultMaxFeeAmountSats() } } @@ -631,11 +725,7 @@ struct LiquidityPolicyView: View { func effectiveMaxFeePercent() -> Double { - if let percent = maxFeePercent() { - return percent - } else { - return defaultMaxFeePercent() - } + return maxFeePercent() ?? defaultMaxFeePercent() } func effectiveMaxFeePercentString() -> String { @@ -695,7 +785,7 @@ struct LiquidityPolicyView: View { } // -------------------------------------------------- - // MARK: View Helpers + // MARK: Helpers: Formatting // -------------------------------------------------- func maxFeeAmtStyler() -> TextFieldNumberStyler { @@ -714,10 +804,6 @@ struct LiquidityPolicyView: View { ) } - // -------------------------------------------------- - // MARK: Static Helpers - // -------------------------------------------------- - static func maxFeeAmtFormater() -> NumberFormatter { let nf = NumberFormatter() @@ -746,6 +832,33 @@ struct LiquidityPolicyView: View { return nf.string(from: NSNumber(value: percent)) ?? "?" } + // -------------------------------------------------- + // MARK: Helpers: Mempool + // -------------------------------------------------- + + func swapEstimationFee() -> Bitcoin_kmpSatoshi? { + + guard let mempoolRecommendedResponse else { + return nil + } + + return mempoolRecommendedResponse.swapEstimationFee(hasNoChannels: true) + } + + // -------------------------------------------------- + // MARK: Tasks + // -------------------------------------------------- + + func fetchMempoolRecommendedFees() async { + + for try await response in MempoolMonitor.shared.stream() { + mempoolRecommendedResponse = response + if Task.isCancelled { + return + } + } + } + // -------------------------------------------------- // MARK: Actions // -------------------------------------------------- diff --git a/phoenix-ios/phoenix-ios/views/configuration/general/display configuration/DisplayConfigurationView.swift b/phoenix-ios/phoenix-ios/views/configuration/general/display configuration/DisplayConfigurationView.swift index 8c0a980db..74ed345f0 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/general/display configuration/DisplayConfigurationView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/general/display configuration/DisplayConfigurationView.swift @@ -359,8 +359,9 @@ fileprivate struct DisplayConfigurationList: View { case .backup : break case .drainWallet : break case .electrum : break - case .backgroundPayments : newNavLinkTag = NavLinkTag.BackgroundPaymentsSelector // RecentPaymentsSelector + case .backgroundPayments : newNavLinkTag = NavLinkTag.BackgroundPaymentsSelector case .liquiditySettings : break + case .forceCloseChannels : break } if let newNavLinkTag = newNavLinkTag { diff --git a/phoenix-ios/phoenix-ios/views/configuration/general/payment options/MaxFeeConfiguration.swift b/phoenix-ios/phoenix-ios/views/configuration/general/payment options/MaxFeeConfiguration.swift index ee42b78a6..e9c0d08e2 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/general/payment options/MaxFeeConfiguration.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/general/payment options/MaxFeeConfiguration.swift @@ -46,9 +46,8 @@ struct MaxFeeConfiguration: View { @State var firstAppearance = true - @Environment(\.smartModalState) var smartModalState: SmartModalState - @EnvironmentObject var deepLinkManager: DeepLinkManager + @EnvironmentObject var smartModalState: SmartModalState enum ExampleHeight: Preference {} let exampleHeightReader = GeometryPreferenceReader( diff --git a/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift b/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift index 2518301b6..f7aefb2bc 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift @@ -11,13 +11,8 @@ fileprivate var log = Logger( fileprivate var log = Logger(OSLog.disabled) #endif -fileprivate enum NavLinkTag: String { - case LiquidityPolicy -} struct PaymentOptionsView: View { - - @State private var navLinkTag: NavLinkTag? = nil @State var defaultPaymentDescription: String = Prefs.shared.defaultPaymentDescription ?? "" @@ -29,18 +24,11 @@ struct PaymentOptionsView: View { @State var payToOpen_feePercent: Double = 0.0 @State var payToOpen_minFeeSat: Int64 = 0 - @State var firstAppearance = true - - @State private var swiftUiBugWorkaround: NavLinkTag? = nil - @State private var swiftUiBugWorkaroundIdx = 0 - let maxFeesPublisher = Prefs.shared.maxFeesPublisher let chainContextPublisher = Biz.business.appConfigurationManager.chainContextPublisher() @Environment(\.openURL) var openURL - @Environment(\.smartModalState) var smartModalState: SmartModalState - - @EnvironmentObject var deepLinkManager: DeepLinkManager + @EnvironmentObject var smartModalState: SmartModalState // -------------------------------------------------- // MARK: View Builders @@ -63,21 +51,12 @@ struct PaymentOptionsView: View { } .listStyle(.insetGrouped) .listBackgroundColor(.primaryBackground) - .onAppear { - onAppear() - } .onReceive(maxFeesPublisher) { maxFeesChanged($0) } .onReceive(chainContextPublisher) { chainContextChanged($0) } - .onChange(of: deepLinkManager.deepLink) { - deepLinkChanged($0) - } - .onChange(of: navLinkTag) { - navLinkTagChanged($0) - } } @ViewBuilder @@ -86,7 +65,6 @@ struct PaymentOptionsView: View { Section { subsection_defaultPaymentDescription() subsection_incomingPaymentExpiry() - subsection_liquidityPolicy() } /* Section.*/header: { Text("Incoming payments") @@ -153,23 +131,6 @@ struct PaymentOptionsView: View { .padding([.top, .bottom], 8) } - @ViewBuilder - func subsection_liquidityPolicy() -> some View { - - navLink(.LiquidityPolicy) { - - VStack(alignment: HorizontalAlignment.leading, spacing: 8) { - Text("Miner fee policy") - - let numSats = liquidityPolicyMaxSats() - Text("Automated (max \(numSats))") - .font(.callout) - .foregroundColor(.secondary) - } - } - .padding([.top, .bottom], 8) - } - @ViewBuilder func section_outgoingPayments() -> some View { @@ -201,38 +162,10 @@ struct PaymentOptionsView: View { } // } - @ViewBuilder - private func navLink( - _ tag: NavLinkTag, - label: () -> Content - ) -> some View where Content: View { - - NavigationLink( - destination: navLinkView(tag), - tag: tag, - selection: $navLinkTag, - label: label - ) - } - - @ViewBuilder - private func navLinkView(_ tag: NavLinkTag) -> some View { - - switch tag { - case .LiquidityPolicy : LiquidityPolicyView() - } - } - // -------------------------------------------------- // MARK: View Helpers // -------------------------------------------------- - func liquidityPolicyMaxSats() -> String { - - let sats = Prefs.shared.liquidityPolicy.effectiveMaxFeeSats - return Utils.formatBitcoin(sat: sats, bitcoinUnit: .sat).string - } - func maxFeesString() -> String { let currentFees = userDefinedMaxFees ?? defaultMaxFees() @@ -252,90 +185,6 @@ struct PaymentOptionsView: View { return formatter.string(from: NSNumber(value: payToOpen_feePercent))! } - // -------------------------------------------------- - // MARK: Notifications - // -------------------------------------------------- - - func onAppear() { - log.trace("onAppear()") - - // SwiftUI BUG, and workaround. - // - // In iOS 14, the row remains selected after we return from the subview. - // For example: - // - Tap on "Fiat Currency" - // - Make a selection or tap "<" to pop back - // - Notice that the "Fiat Currency" row is still selected (e.g. has gray background) - // - // There are several workaround for this issue: - // https://developer.apple.com/forums/thread/660468 - // - // We are implementing the least risky solution. - // Which requires us to change the `Section.id` property. - - if firstAppearance { - firstAppearance = false - - if let deepLink = deepLinkManager.deepLink { - DispatchQueue.main.async { // iOS 14 issues workaround - deepLinkChanged(deepLink) - } - } - } - } - - func deepLinkChanged(_ value: DeepLink?) { - log.trace("deepLinkChanged() => \(value?.rawValue ?? "nil")") - - // This is a hack, courtesy of bugs in Apple's NavigationLink: - // https://developer.apple.com/forums/thread/677333 - // - // Summary: - // There's some quirky code in SwiftUI that is resetting our navLinkTag. - // Several bizarre workarounds have been proposed. - // I've tried every one of them, and none of them work (at least, without bad side-effects). - // - // The only clean solution I've found is to listen for SwiftUI's bad behaviour, - // and forcibly undo it. - - if let value = value { - - // Navigate towards deep link (if needed) - var newNavLinkTag: NavLinkTag? = nil - switch value { - case .paymentHistory : break - case .backup : break - case .drainWallet : break - case .electrum : break - case .backgroundPayments : break - case .liquiditySettings : newNavLinkTag = NavLinkTag.LiquidityPolicy - } - - if let newNavLinkTag = newNavLinkTag { - - self.swiftUiBugWorkaround = newNavLinkTag - self.swiftUiBugWorkaroundIdx += 1 - clearSwiftUiBugWorkaround(delay: 1.5) - - self.navLinkTag = newNavLinkTag // Trigger/push the view - } - - } else { - // We reached the final destination of the deep link - clearSwiftUiBugWorkaround(delay: 0.0) - } - } - - fileprivate func navLinkTagChanged(_ tag: NavLinkTag?) { - log.trace("navLinkTagChanged() => \(tag?.rawValue ?? "nil")") - - if tag == nil, let forcedNavLinkTag = swiftUiBugWorkaround { - - log.debug("Blocking SwiftUI's attempt to reset our navLinkTag") - self.navLinkTag = forcedNavLinkTag - } - } - // -------------------------------------------------- // MARK: Actions // -------------------------------------------------- @@ -380,23 +229,6 @@ struct PaymentOptionsView: View { payToOpen_feePercent = context.payToOpen.v1.feePercent * 100 // 0.01 => 1% payToOpen_minFeeSat = context.payToOpen.v1.minFeeSat } - - // -------------------------------------------------- - // MARK: Workarounds - // -------------------------------------------------- - - func clearSwiftUiBugWorkaround(delay: TimeInterval) { - - let idx = self.swiftUiBugWorkaroundIdx - - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - - if self.swiftUiBugWorkaroundIdx == idx { - log.trace("swiftUiBugWorkaround = nil") - self.swiftUiBugWorkaround = nil - } - } - } } func defaultMaxFees() -> MaxFees { diff --git a/phoenix-ios/phoenix-ios/views/configuration/security/AppAccessView.swift b/phoenix-ios/phoenix-ios/views/configuration/privacy and security/AppAccessView.swift similarity index 100% rename from phoenix-ios/phoenix-ios/views/configuration/security/AppAccessView.swift rename to phoenix-ios/phoenix-ios/views/configuration/privacy and security/AppAccessView.swift diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/privacy/PaymentsBackupView.swift b/phoenix-ios/phoenix-ios/views/configuration/privacy and security/PaymentsBackupView.swift similarity index 100% rename from phoenix-ios/phoenix-ios/views/configuration/advanced/privacy/PaymentsBackupView.swift rename to phoenix-ios/phoenix-ios/views/configuration/privacy and security/PaymentsBackupView.swift diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/privacy/TorConfigurationView.swift b/phoenix-ios/phoenix-ios/views/configuration/privacy and security/TorConfigurationView.swift similarity index 100% rename from phoenix-ios/phoenix-ios/views/configuration/advanced/privacy/TorConfigurationView.swift rename to phoenix-ios/phoenix-ios/views/configuration/privacy and security/TorConfigurationView.swift diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/privacy/ElectrumAddressSheet.swift b/phoenix-ios/phoenix-ios/views/configuration/privacy and security/electrum server/ElectrumAddressSheet.swift similarity index 99% rename from phoenix-ios/phoenix-ios/views/configuration/advanced/privacy/ElectrumAddressSheet.swift rename to phoenix-ios/phoenix-ios/views/configuration/privacy and security/electrum server/ElectrumAddressSheet.swift index 2087ed41b..20c06a185 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/advanced/privacy/ElectrumAddressSheet.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/privacy and security/electrum server/ElectrumAddressSheet.swift @@ -80,7 +80,7 @@ struct ElectrumAddressSheet: View { @State var titleWidth: CGFloat? = nil @Environment(\.colorScheme) var colorScheme: ColorScheme - @Environment(\.smartModalState) var smartModalState: SmartModalState + @EnvironmentObject var smartModalState: SmartModalState init(mvi: MVIState) { self.mvi = mvi diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/privacy/ElectrumConfigurationView.swift b/phoenix-ios/phoenix-ios/views/configuration/privacy and security/electrum server/ElectrumConfigurationView.swift similarity index 98% rename from phoenix-ios/phoenix-ios/views/configuration/advanced/privacy/ElectrumConfigurationView.swift rename to phoenix-ios/phoenix-ios/views/configuration/privacy and security/electrum server/ElectrumConfigurationView.swift index 02c94a5e0..df0dd219b 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/advanced/privacy/ElectrumConfigurationView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/privacy and security/electrum server/ElectrumConfigurationView.swift @@ -24,8 +24,7 @@ struct ElectrumConfigurationView: MVIView { @State var didAppear = false @EnvironmentObject var deepLinkManager: DeepLinkManager - - @Environment(\.smartModalState) var smartModalState: SmartModalState + @EnvironmentObject var smartModalState: SmartModalState func connectionInfo() -> (String, String) { diff --git a/phoenix-ios/phoenix-ios/views/configuration/general/recovery phrase/CloudBackupView.swift b/phoenix-ios/phoenix-ios/views/configuration/privacy and security/recovery phrase/CloudBackupView.swift similarity index 100% rename from phoenix-ios/phoenix-ios/views/configuration/general/recovery phrase/CloudBackupView.swift rename to phoenix-ios/phoenix-ios/views/configuration/privacy and security/recovery phrase/CloudBackupView.swift diff --git a/phoenix-ios/phoenix-ios/views/configuration/general/recovery phrase/RecoveryPhraseView.swift b/phoenix-ios/phoenix-ios/views/configuration/privacy and security/recovery phrase/RecoveryPhraseView.swift similarity index 100% rename from phoenix-ios/phoenix-ios/views/configuration/general/recovery phrase/RecoveryPhraseView.swift rename to phoenix-ios/phoenix-ios/views/configuration/privacy and security/recovery phrase/RecoveryPhraseView.swift diff --git a/phoenix-ios/phoenix-ios/views/content/ContentView.swift b/phoenix-ios/phoenix-ios/views/content/ContentView.swift index 34813e650..3e1c27900 100644 --- a/phoenix-ios/phoenix-ios/views/content/ContentView.swift +++ b/phoenix-ios/phoenix-ios/views/content/ContentView.swift @@ -17,11 +17,11 @@ struct ContentView: View { @ObservedObject var lockState = LockState.shared @State var unlockedOnce = false - @Environment(\.shortSheetState) private var shortSheetState: ShortSheetState - @State private var shortSheetItem: ShortSheetItem? = nil + @EnvironmentObject var shortSheetState: ShortSheetState + @State var shortSheetItem: ShortSheetItem? = nil - @Environment(\.popoverState) private var popoverState: PopoverState - @State private var popoverItem: PopoverItem? = nil + @EnvironmentObject var popoverState: PopoverState + @State var popoverItem: PopoverItem? = nil @ViewBuilder var body: some View { diff --git a/phoenix-ios/phoenix-ios/views/content/RootView.swift b/phoenix-ios/phoenix-ios/views/content/RootView.swift index 662e4fe2f..6f917a2fe 100644 --- a/phoenix-ios/phoenix-ios/views/content/RootView.swift +++ b/phoenix-ios/phoenix-ios/views/content/RootView.swift @@ -8,7 +8,7 @@ struct RootView: View { GeometryReader { geometry in ContentView() .frame(width: geometry.size.width, height: geometry.size.height, alignment: .center) - .modifier(GlobalEnvironment()) + .modifier(GlobalEnvironment.mainInstance()) .onAppear { GlobalEnvironment.deviceInfo._windowSize = geometry.size GlobalEnvironment.deviceInfo.windowSafeArea = geometry.safeAreaInsets diff --git a/phoenix-ios/phoenix-ios/views/environment/DeepLink.swift b/phoenix-ios/phoenix-ios/views/environment/DeepLink.swift index 2d1dd3808..feac77ee2 100644 --- a/phoenix-ios/phoenix-ios/views/environment/DeepLink.swift +++ b/phoenix-ios/phoenix-ios/views/environment/DeepLink.swift @@ -18,6 +18,7 @@ enum DeepLink: String, Equatable { case electrum case backgroundPayments case liquiditySettings + case forceCloseChannels } class DeepLinkManager: ObservableObject { @@ -77,3 +78,28 @@ class DeepLinkManager: ObservableObject { } } } + +enum PopToDestination: CustomStringConvertible { + case RootView(followedBy: DeepLink? = nil) + case ConfigurationView(followedBy: DeepLink? = nil) + case TransactionsView + + var followedBy: DeepLink? { + switch self { + case .RootView(let followedBy) : return followedBy + case .ConfigurationView(let followedBy) : return followedBy + case .TransactionsView : return nil + } + } + + public var description: String { + switch self { + case .RootView(let followedBy): + return "RootView(followedBy: \(followedBy?.rawValue ?? "nil"))" + case .ConfigurationView(let followedBy): + return "ConfigurationView(follwedBy: \(followedBy?.rawValue ?? "nil"))" + case .TransactionsView: + return "TransactionsView" + } + } +} diff --git a/phoenix-ios/phoenix-ios/views/environment/GlobalEnvironment.swift b/phoenix-ios/phoenix-ios/views/environment/GlobalEnvironment.swift index d29dd5bde..9168449b2 100644 --- a/phoenix-ios/phoenix-ios/views/environment/GlobalEnvironment.swift +++ b/phoenix-ios/phoenix-ios/views/environment/GlobalEnvironment.swift @@ -9,6 +9,33 @@ struct GlobalEnvironment: ViewModifier { static var deepLinkManager = DeepLinkManager() static var controllerFactory = Biz.business.controllers + let popoverState: PopoverState + let shortSheetState: ShortSheetState + let smartModalState: SmartModalState + + private static var instance_main: GlobalEnvironment? = nil + private static var instance_sheet: GlobalEnvironment? = nil + + static func mainInstance() -> GlobalEnvironment { + if instance_main == nil { + instance_main = GlobalEnvironment( + popoverState: PopoverState(), + shortSheetState: ShortSheetState() + ) + } + return instance_main! + } + + static func sheetInstance() -> GlobalEnvironment { + if instance_sheet == nil { + instance_sheet = GlobalEnvironment( + popoverState: PopoverState(), + shortSheetState: ShortSheetState() + ) + } + return instance_sheet! + } + static func reset() { deviceInfo = DeviceInfo() currencyPrefs = CurrencyPrefs() @@ -16,11 +43,23 @@ struct GlobalEnvironment: ViewModifier { controllerFactory = Biz.business.controllers } + private init( + popoverState: PopoverState, + shortSheetState: ShortSheetState + ) { + self.popoverState = popoverState + self.shortSheetState = shortSheetState + self.smartModalState = SmartModalState(popoverState: popoverState, shortSheetState: shortSheetState) + } + func body(content: Self.Content) -> some View { content .environmentObject(Self.deviceInfo) .environmentObject(Self.currencyPrefs) .environmentObject(Self.deepLinkManager) .environment(\.controllerFactory, Self.controllerFactory) + .environmentObject(self.popoverState) + .environmentObject(self.shortSheetState) + .environmentObject(self.smartModalState) } } diff --git a/phoenix-ios/phoenix-ios/views/html/Base.lproj/liquidity.html b/phoenix-ios/phoenix-ios/views/html/Base.lproj/liquidity.html index b159e4fda..ae802e48f 100644 --- a/phoenix-ios/phoenix-ios/views/html/Base.lproj/liquidity.html +++ b/phoenix-ios/phoenix-ios/views/html/Base.lproj/liquidity.html @@ -22,14 +22,14 @@

When you receive a payment on L1, Phoenix will automatically - move the funds to L2 IF the miner fees adhere to your + move the funds to L2 IF the fees adhere to your configured fee policy.

Payments you receive on L2 can be received instantly and for zero fees. However, occasionally an L1 operation is also required in order to manage the L2 payment channel. This can - be done automatically IF the miner fees adhere to your + be done automatically IF the fees adhere to your configured fee policy.

diff --git a/phoenix-ios/phoenix-ios/views/inspect/CpfpView.swift b/phoenix-ios/phoenix-ios/views/inspect/CpfpView.swift new file mode 100644 index 000000000..d5964c2d8 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/inspect/CpfpView.swift @@ -0,0 +1,742 @@ +import SwiftUI +import PhoenixShared +import os.log + +#if DEBUG && true +fileprivate var log = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: "CpfpSheet" +) +#else +fileprivate var log = Logger(OSLog.disabled) +#endif + +struct MinerFeeCPFP { + + /// The effective feerate of the bumped transaction(s) + let effectiveFeerate: Lightning_kmpFeeratePerByte + + /// The feerate to use for the CPFP transaction itself. + /// It should be significantly higher than the effectiveFeerate since + /// it's only purpose is to increase the "effective" feerate of parent transaction(s). + let cpfpTxFeerate: Lightning_kmpFeeratePerByte + + /// The miner fee that will be incurred to execute the CPFP transaction. + let minerFee: Bitcoin_kmpSatoshi +} + +enum CpfpError: Error { + case feeTooLow + case noChannels + case errorThrown(message: String) + case executeError(problem: SpliceOutProblem) +} + + +struct CpfpView: View { + + let type: PaymentViewType + let onChainPayment: Lightning_kmpOnChainOutgoingPayment + + @State var minerFeeInfo: MinerFeeCPFP? + @State var satsPerByte: String = "" + @State var parsedSatsPerByte: Result = .failure(.emptyInput) + @State var mempoolRecommendedResponse: MempoolRecommendedResponse? = nil + + @State var cpfpError: CpfpError? = nil + @State var txAlreadyMined: Bool = false + @State var spliceInProgress: Bool = false + + @State var explicitlySelectedPriority: MinerFeePriority? = nil + + enum Field: Hashable { + case satsPerByteTextField + } + @FocusState private var focusedField: Field? + + enum NavBarButtonWidth: Preference {} + let navBarButtonWidthReader = GeometryPreferenceReader( + key: AppendValue.self, + value: { [$0.size.width] } + ) + @State var navBarButtonWidth: CGFloat? = nil + + enum PriorityBoxWidth: Preference {} + let priorityBoxWidthReader = GeometryPreferenceReader( + key: AppendValue.self, + value: { [$0.size.width] } + ) + @State var priorityBoxWidth: CGFloat? = nil + + @Environment(\.presentationMode) var presentationMode: Binding + + @EnvironmentObject var currencyPrefs: CurrencyPrefs + + // -------------------------------------------------- + // MARK: View Builders + // -------------------------------------------------- + + @ViewBuilder + var body: some View { + + switch type { + case .sheet: + main() + .navigationTitle(NSLocalizedString("Accelerate Transactions", comment: "Navigation bar title")) + .navigationBarTitleDisplayMode(.inline) + .navigationBarHidden(true) + + case .embedded: + main() + .navigationTitle(NSLocalizedString("Accelerate Transactions", comment: "Navigation bar title")) + .navigationBarTitleDisplayMode(.inline) + .background( + Color.primaryBackground.ignoresSafeArea(.all, edges: .bottom) + ) + } + } + + @ViewBuilder + func main() -> some View { + + VStack(alignment: HorizontalAlignment.center, spacing: 0) { + + header() + ScrollView { + content() + } + } + .onChange(of: satsPerByte) { _ in + satsPerByteChanged() + } + .onChange(of: mempoolRecommendedResponse) { _ in + mempoolRecommendedResponseChanged() + } + .task { + await fetchMempoolRecommendedFees() + } + .task { + await monitorBlockchain() + } + } + + @ViewBuilder + func header() -> some View { + + if case .sheet(let closeAction) = type { + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Button { + presentationMode.wrappedValue.dismiss() + } label: { + Image(systemName: "chevron.backward") + .imageScale(.medium) + .font(.title3.weight(.semibold)) + } + .read(navBarButtonWidthReader) + .frame(width: navBarButtonWidth) + + Spacer(minLength: 0) + Text("Accelerate transaction") + .font(.headline) + .fontWeight(.medium) + .lineLimit(1) + Spacer(minLength: 0) + + Button { + closeAction() + } label: { + Image(systemName: "xmark") // must match size of chevron.backward above + .imageScale(.medium) + .font(.title3) + } + .read(navBarButtonWidthReader) + .frame(width: navBarButtonWidth) + + } // + .padding() + .assignMaxPreference(for: navBarButtonWidthReader.key, to: $navBarButtonWidth) + } + } + + @ViewBuilder + func content() -> some View { + + VStack(alignment: HorizontalAlignment.center, spacing: 20) { + + Spacer().frame(height: 25) + + Text( + """ + You can make all your unconfirmed transactions use a higher effective feerate \ + to encourage miners to favour your payments. + """ + ) + .font(.callout) + + priorityBoxes() + .padding(.top) + .padding(.bottom) + + minerFeeFormula() + .padding(.bottom) + + payButton() + } + .padding(.horizontal, 40) + } + + @ViewBuilder + func priorityBoxes() -> some View { + + if #available(iOS 16.0, *) { + priorityBoxes_ios16() + } else { + priorityBoxes_ios15() + } + } + + @ViewBuilder + @available(iOS 16.0, *) + func priorityBoxes_ios16() -> some View { + + ViewThatFits { + Grid(horizontalSpacing: 8, verticalSpacing: 8) { + GridRow(alignment: VerticalAlignment.center) { + priorityBox_economy() + priorityBox_low() + priorityBox_medium() + priorityBox_high() + } + } // + Grid(horizontalSpacing: 8, verticalSpacing: 8) { + GridRow(alignment: VerticalAlignment.center) { + priorityBox_economy() + priorityBox_low() + + } + GridRow(alignment: VerticalAlignment.center) { + priorityBox_medium() + priorityBox_high() + } + } // + } + .assignMaxPreference(for: priorityBoxWidthReader.key, to: $priorityBoxWidth) + } + + @ViewBuilder + func priorityBoxes_ios15() -> some View { + + VStack(alignment: HorizontalAlignment.center, spacing: 8) { + HStack(alignment: VerticalAlignment.center, spacing: 8) { + priorityBox_economy() + priorityBox_low() + } + HStack(alignment: VerticalAlignment.center, spacing: 8) { + priorityBox_medium() + priorityBox_high() + } + } + .assignMaxPreference(for: priorityBoxWidthReader.key, to: $priorityBoxWidth) + } + + @ViewBuilder + func priorityBox_economy() -> some View { + + GroupBox { + VStack(alignment: HorizontalAlignment.center, spacing: 4) { + Text("\(satsPerByteString(.none)) sats/vByte") + .font(.subheadline) + .foregroundColor(.secondary) + Text("โ‰ˆ 1+ days") + } + } label: { + Text("No Priority") + } + .groupBoxStyle(PriorityBoxStyle( + width: priorityBoxWidth, + disabled: isPriorityDisabled(), + selected: isPrioritySelected(.none), + tapped: { priorityTapped(.none) } + )) + .read(priorityBoxWidthReader) + } + + @ViewBuilder + func priorityBox_low() -> some View { + + GroupBox { + VStack(alignment: HorizontalAlignment.center, spacing: 4) { + Text("\(satsPerByteString(.low)) sats/vByte") + .font(.subheadline) + .foregroundColor(.secondary) + Text("โ‰ˆ 1 hour") + } + } label: { + Text("Low Priority") + } + .groupBoxStyle(PriorityBoxStyle( + width: priorityBoxWidth, + disabled: isPriorityDisabled(), + selected: isPrioritySelected(.low), + tapped: { priorityTapped(.low) } + )) + .read(priorityBoxWidthReader) + } + + @ViewBuilder + func priorityBox_medium() -> some View { + + GroupBox { + VStack(alignment: HorizontalAlignment.center, spacing: 4) { + Text("\(satsPerByteString(.medium)) sats/vByte") + .font(.subheadline) + .foregroundColor(.secondary) + Text("โ‰ˆ 30 minutes") + } + } label: { + Text("Medium Priority") + } + .groupBoxStyle(PriorityBoxStyle( + width: priorityBoxWidth, + disabled: isPriorityDisabled(), + selected: isPrioritySelected(.medium), + tapped: { priorityTapped(.medium) } + )) + .read(priorityBoxWidthReader) + } + + @ViewBuilder + func priorityBox_high() -> some View { + + GroupBox { + VStack(alignment: HorizontalAlignment.center, spacing: 4) { + Text("\(satsPerByteString(.high)) sats/vByte") + .font(.subheadline) + .foregroundColor(.secondary) + Text("โ‰ˆ 10 minutes") + } + } label: { + Text("High Priority") + } + .groupBoxStyle(PriorityBoxStyle( + width: priorityBoxWidth, + disabled: isPriorityDisabled(), + selected: isPrioritySelected(.high), + tapped: { priorityTapped(.high) } + )) + .read(priorityBoxWidthReader) + } + + @ViewBuilder + func minerFeeFormula() -> some View { + + VStack(alignment: HorizontalAlignment.center, spacing: 10) { + satsPerByteTextField() + minerFeeAmounts() + } + } + + @ViewBuilder + func satsPerByteTextField() -> some View { + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + + TextField("", text: satsPerByteStyler().amountProxy) + .keyboardType(.numberPad) + .focused($focusedField, equals: .satsPerByteTextField) + .frame(maxWidth: 40) + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isInvalidSatsPerByte ? Color.appNegative : Color.textFieldBorder, lineWidth: 1) + ) + + Text(verbatim: "sats/vByte") + .font(.callout) + .foregroundColor(.secondary) + .padding(.leading, 4) + } + } + + @ViewBuilder + func minerFeeAmounts() -> some View { + + Group { + if case .failure = parsedSatsPerByte { + + Text("Select a fee to see the amount.") + .foregroundColor(.secondary) + + } else if minerFeeInfo == nil { + + Text("Calculating amountโ€ฆ") + .foregroundColor(cpfpError == nil ? .secondary : .clear) + + } else { + + let (btc, fiat) = minerFeeStrings() + Text("You will pay \(btc.string) (โ‰ˆ \(fiat.string)) to the Bitcoin miners.") + } + } //
+ .font(.callout) + .multilineTextAlignment(.center) + } + + @ViewBuilder + func payButton() -> some View { + + VStack(alignment: HorizontalAlignment.center, spacing: 10) { + + Button { + executePayment() + } label: { + Label("Pay", systemImage: "paperplane") + .font(.title3) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .disabled(minerFeeInfo == nil || cpfpError != nil || txAlreadyMined || spliceInProgress) + + if txAlreadyMined { + + Text("Good news! Your transaction has been mined!") + .foregroundColor(.appPositive) + .font(.callout) + .multilineTextAlignment(.center) + + } else if let cpfpError { + + Group { + switch cpfpError { + case .feeTooLow: + Text( + """ + This feerate is below what your transactions are already using. \ + It should be higher to have any effects. + """ + ) + + case .noChannels: + Text("No available channels. Please check your internet connection.") + + case .errorThrown(let message): + Text("Unexpected error: \(message)") + + case .executeError(let problem): + Text(problem.localizedDescription()) + } + } //
+ .font(.callout) + .foregroundColor(.appNegative) + .multilineTextAlignment(.center) + } + } + } + + // -------------------------------------------------- + // MARK: Tasks + // -------------------------------------------------- + + func fetchMempoolRecommendedFees() async { + + for try await response in MempoolMonitor.shared.stream() { + mempoolRecommendedResponse = response + if Task.isCancelled { + return + } + } + } + + func checkConfirmations() async { + log.trace("checkConfirmations()") + + do { + let result = try await Biz.business.electrumClient.kotlin_getConfirmations(txid: onChainPayment.txId) + + let confirmations = result?.intValue ?? 0 + log.debug("checkConfirmations(): => \(confirmations)") + + if confirmations > 0 { + self.txAlreadyMined = true + } + + } catch { + log.error("electrumClient.getConfirmations(): \(error)") + } + } + + func monitorBlockchain() async { + log.trace("monitorBlockchain()") + + for await notification in Biz.business.electrumClient.notificationsPublisher().values { + + if notification is Lightning_kmpHeaderSubscriptionResponse { + // A new block was mined ! + // Check to see if our pending transaction was included in the block. + await checkConfirmations() + } else { + log.debug("monitorBlockchain(): notification =!= HeaderSubscriptionResponse") + } + + if Task.isCancelled { + log.debug("monitorBlockchain(): Task.isCancelled") + break + } else { + log.debug("monitorBlockchain(): Waiting for next electrum notification...") + } + } + } + + // -------------------------------------------------- + // MARK: View Helpers + // -------------------------------------------------- + + var isInvalidSatsPerByte: Bool { + switch parsedSatsPerByte { + case .success(_): + return false + + case .failure(let reason): + switch reason { + case .emptyInput : return false + case .invalidInput : return true + } + } + } + + func satsPerByteStyler() -> TextFieldNumberStyler { + + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + + return TextFieldNumberStyler( + formatter: formatter, + amount: $satsPerByte, + parsedAmount: $parsedSatsPerByte, + userDidEdit: userDidEditSatsPerByteField + ) + } + + func satsPerByte(_ priority: MinerFeePriority) -> (Double, String)? { + + guard let mempoolRecommendedResponse else { + return nil + } + + let doubleValue = mempoolRecommendedResponse.feeForPriority(priority) + + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.minimumFractionDigits = 0 + nf.maximumFractionDigits = 1 + + let stringValue = nf.string(from: NSNumber(value: doubleValue)) ?? "?" + + return (doubleValue, stringValue) + } + + func satsPerByteString(_ priority: MinerFeePriority) -> String { + + let tuple = satsPerByte(priority) + return tuple?.1 ?? "?" + } + + func isPriorityDisabled() -> Bool { + + return (mempoolRecommendedResponse == nil) + } + + func isPrioritySelected(_ priority: MinerFeePriority) -> Bool { + + guard let mempoolRecommendedResponse else { + return false + } + + if let explicitlySelectedPriority { + return explicitlySelectedPriority == priority + } + + guard let amount = try? parsedSatsPerByte.get() else { + return false + } + + switch priority { + case .none: + return amount.doubleValue == mempoolRecommendedResponse.feeForPriority(.none) + + case .low: + return amount.doubleValue == mempoolRecommendedResponse.feeForPriority(.low) && + amount.doubleValue != mempoolRecommendedResponse.feeForPriority(.none) + + case .medium: + return amount.doubleValue == mempoolRecommendedResponse.feeForPriority(.medium) && + amount.doubleValue != mempoolRecommendedResponse.feeForPriority(.high) + + case .high: + return amount.doubleValue == mempoolRecommendedResponse.feeForPriority(.high) && + amount.doubleValue != mempoolRecommendedResponse.feeForPriority(.medium) + } + } + + func minerFeeStrings() -> (FormattedAmount, FormattedAmount) { + + guard let minerFeeInfo else { + let btc = Utils.unknownBitcoinAmount(bitcoinUnit: currencyPrefs.bitcoinUnit) + let fiat = Utils.unknownFiatAmount(fiatCurrency: currencyPrefs.fiatCurrency) + return (btc, fiat) + } + + let btc = Utils.formatBitcoin(currencyPrefs, sat: minerFeeInfo.minerFee) + let fiat = Utils.formatFiat(currencyPrefs, sat: minerFeeInfo.minerFee) + return (btc, fiat) + } + + // -------------------------------------------------- + // MARK: Actions + // -------------------------------------------------- + + func priorityTapped(_ priority: MinerFeePriority) { + log.trace("priorityTapped()") + + guard let tuple = satsPerByte(priority) else { + return + } + + explicitlySelectedPriority = priority + parsedSatsPerByte = .success(NSNumber(value: tuple.0)) + satsPerByte = tuple.1 + } + + func userDidEditSatsPerByteField() { + log.trace("userDidEditSatsPerByteField()") + + explicitlySelectedPriority = nil + } + + func satsPerByteChanged() { + log.trace("satsPerByteChanged(): \(satsPerByte)") + + guard + let satsPerByte_number = try? parsedSatsPerByte.get(), + let peer = Biz.business.getPeer() + else { + minerFeeInfo = nil + return + } + + let originalSatsPerByte = satsPerByte + + let satsPerByte_satoshi = Bitcoin_kmpSatoshi(sat: satsPerByte_number.int64Value) + let effectiveFeerate = Lightning_kmpFeeratePerByte(feerate: satsPerByte_satoshi) + let effectiveFeeratePerKw = Lightning_kmpFeeratePerKw(feeratePerByte: effectiveFeerate) + + cpfpError = nil + minerFeeInfo = nil + + Task { @MainActor in + + var pair: KotlinPair? = nil + do { + pair = try await peer.estimateFeeForSpliceCpfp( + channelId: onChainPayment.channelId, + targetFeerate: effectiveFeeratePerKw + ) + } catch { + log.error("Error: \(error)") + } + + guard self.satsPerByte == originalSatsPerByte else { + // Ignore: user has changed to a different rate + return + } + + if let pair { + + let cpfpFeeratePerKw: Lightning_kmpFeeratePerKw = pair.first! + let cpfpFeerate = Lightning_kmpFeeratePerByte(feeratePerKw: cpfpFeeratePerKw) + + let minerFee: Bitcoin_kmpSatoshi = pair.second! + + // From the docs (in lightning-kmp): + // + // > if the output feerate is equal to the input feerate then the cpfp is useless + // > and should not be attempted. + // + // So we check to ensure the output is larger than the input. + + let input: Int64 = effectiveFeerate.feerate.sat + let output: Int64 = cpfpFeerate.feerate.sat + + log.debug("effectiveFeerate(\(input)) => cpfpFeerate(\(output))") + + if Double(output) > (Double(input) * 1.1) { + self.minerFeeInfo = MinerFeeCPFP( + effectiveFeerate: effectiveFeerate, + cpfpTxFeerate: cpfpFeerate, + minerFee: minerFee + ) + } else { + log.error("Error: peer.estimateFeeForSpliceCpfp() => too low") + self.cpfpError = .feeTooLow + } + + } else { + log.error("Error: peer.estimateFeeForSpliceCpfp() => nil") + self.cpfpError = .noChannels + } + + } // + } + + func mempoolRecommendedResponseChanged() { + log.trace("mempoolRecommendedResponseChanged()") + + // The UI will change, so we need to reset the geometry measurements + priorityBoxWidth = nil + } + + func executePayment() { + log.trace("executePayment()") + + guard + let minerFeeInfo = minerFeeInfo, + let peer = Biz.business.getPeer() + else { + return + } + + spliceInProgress = true + Task { @MainActor in + + do { + let feeratePerByte = minerFeeInfo.cpfpTxFeerate + let feeratePerKw = Lightning_kmpFeeratePerKw(feeratePerByte: feeratePerByte) + + let response = try await peer.spliceCpfp( + channelId: onChainPayment.channelId, + feerate: feeratePerKw + ) + + if let problem = SpliceOutProblem.fromResponse(response) { + self.cpfpError = .executeError(problem: problem) + } else { + switch type { + case .sheet(let closeAction): + closeAction() + case .embedded(let popTo): + popTo(.TransactionsView) + self.presentationMode.wrappedValue.dismiss() + } + } + + } catch { + log.error("peer.spliceCpfp(): error: \(error)") + self.spliceInProgress = false + } + } // + } +} diff --git a/phoenix-ios/phoenix-ios/views/inspect/DetailsView.swift b/phoenix-ios/phoenix-ios/views/inspect/DetailsView.swift index 3d0cb71d4..7f25f7541 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/DetailsView.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/DetailsView.swift @@ -55,16 +55,17 @@ struct DetailsView: View { } label: { Image(systemName: "chevron.backward") .imageScale(.medium) + .font(.title3.weight(.semibold)) } Spacer() Button { closeAction() } label: { - Image(systemName: "xmark") // must match size of chevron.backward above + Image(systemName: "xmark") .imageScale(.medium) + .font(.title3) } } // - .font(.title2) .padding() } else { @@ -93,6 +94,8 @@ fileprivate struct DetailsInfoGrid: InfoGridView { @State var showBlockchainExplorerOptions = false + @State var truncatedText: [String: Bool] = [:] + // let minKeyColumnWidth: CGFloat = 50 let maxKeyColumnWidth: CGFloat = 140 @@ -169,7 +172,7 @@ fileprivate struct DetailsInfoGrid: InfoGridView { header("Payment Received") } content: { paymentReceived_receivedAt(received) - paymentReceived_amountReceived(received) + amountReceived(msat: received.amount) payment_standardFees(incomingPayment) payment_minerFees(incomingPayment) } @@ -235,9 +238,9 @@ fileprivate struct DetailsInfoGrid: InfoGridView { } content: { offChain_completedAt(offChain) offChain_elapsed(outgoingPayment) - offChain_amountSent(outgoingPayment) + outgoing_amountSent(outgoingPayment) offChain_fees(outgoingPayment) - offChain_amountReceived(lightningPayment) + amountReceived(msat: lightningPayment.recipientAmount) offChain_recipientPubkey(lightningPayment) } @@ -267,8 +270,11 @@ fileprivate struct DetailsInfoGrid: InfoGridView { InlineSection { header("Splice Out") } content: { + onChain_broadcastAt(spliceOut) onChain_confirmedAt(spliceOut) - onChain_claimed(spliceOut) + outgoing_amountSent(outgoingPayment) + onChain_minerFees(spliceOut) + amountReceived(sat: spliceOut.recipientAmount) onChain_btcTxid(spliceOut) } @@ -286,10 +292,24 @@ fileprivate struct DetailsInfoGrid: InfoGridView { InlineSection { header("Closing Status") } content: { + onChain_broadcastAt(channelClosing) onChain_confirmedAt(channelClosing) - onChain_claimed(channelClosing) + outgoing_amountSent(outgoingPayment) + onChain_minerFees(channelClosing) + amountReceived(sat: channelClosing.recipientAmount) onChain_btcTxid(channelClosing) } + + } else if let spliceCpfp = outgoingPayment as? Lightning_kmpSpliceCpfpOutgoingPayment { + + InlineSection { + header("Bump Fee (CPFP)") + } content: { + onChain_broadcastAt(spliceCpfp) + onChain_confirmedAt(spliceCpfp) + onChain_minerFees(spliceCpfp) + onChain_btcTxid(spliceCpfp) + } } } @@ -480,10 +500,13 @@ fileprivate struct DetailsInfoGrid: InfoGridView { } valueColumn: { if let msat = paymentRequest.amount { - commonValue_amounts(displayAmounts: displayAmounts( - msat: msat, - originalFiat: nil // we don't have this info (at time of invoice generation) - )) + commonValue_amounts( + identifier: identifier, + displayAmounts: displayAmounts( + msat: msat, + originalFiat: nil // we don't have this info (at time of invoice generation) + ) + ) } else { Text("Any amount") } @@ -584,28 +607,6 @@ fileprivate struct DetailsInfoGrid: InfoGridView { } } - @ViewBuilder - func paymentReceived_amountReceived( - _ received: Lightning_kmpIncomingPayment.Received - ) -> some View { - let identifier: String = #function - - InfoGridRowWrapper( - identifier: identifier, - keyColumnWidth: keyColumnWidth(identifier: identifier) - ) { - keyColumn("amount received") - - } valueColumn: { - - let msat = received.receivedWith.map { $0.amount.msat }.reduce(0, +) - commonValue_amounts(displayAmounts: displayAmounts( - msat: Lightning_kmpMilliSatoshi(msat: msat), - originalFiat: paymentInfo.metadata.originalFiat - )) - } - } - @ViewBuilder func swapOut_address(_ address: String) -> some View { let identifier: String = #function @@ -740,10 +741,13 @@ fileprivate struct DetailsInfoGrid: InfoGridView { } valueColumn: { - commonValue_amounts(displayAmounts: displayAmounts( - msat: Lightning_kmpMilliSatoshi(msat: standardFees.0), - originalFiat: paymentInfo.metadata.originalFiat - )) + commonValue_amounts( + identifier: identifier, + displayAmounts: displayAmounts( + msat: Lightning_kmpMilliSatoshi(msat: standardFees.0), + originalFiat: paymentInfo.metadata.originalFiat + ) + ) } } } @@ -765,10 +769,13 @@ fileprivate struct DetailsInfoGrid: InfoGridView { } valueColumn: { - commonValue_amounts(displayAmounts: displayAmounts( - msat: Lightning_kmpMilliSatoshi(msat: minerFees.0), - originalFiat: paymentInfo.metadata.originalFiat - )) + commonValue_amounts( + identifier: identifier, + displayAmounts: displayAmounts( + msat: Lightning_kmpMilliSatoshi(msat: minerFees.0), + originalFiat: paymentInfo.metadata.originalFiat + ) + ) } } } @@ -925,27 +932,6 @@ fileprivate struct DetailsInfoGrid: InfoGridView { } } - @ViewBuilder - func offChain_amountReceived( - _ outgoingPayment: Lightning_kmpLightningOutgoingPayment - ) -> some View { - let identifier: String = #function - - InfoGridRowWrapper( - identifier: identifier, - keyColumnWidth: keyColumnWidth(identifier: identifier) - ) { - keyColumn("amount received") - - } valueColumn: { - - commonValue_amounts(displayAmounts: displayAmounts( - msat: outgoingPayment.recipientAmount, - originalFiat: paymentInfo.metadata.originalFiat - )) - } - } - @ViewBuilder func offChain_fees(_ outgoingPayment: Lightning_kmpOutgoingPayment) -> some View { let identifier: String = #function @@ -959,6 +945,7 @@ fileprivate struct DetailsInfoGrid: InfoGridView { } valueColumn: { commonValue_amounts( + identifier: identifier, displayAmounts: displayAmounts( msat: outgoingPayment.fees, originalFiat: paymentInfo.metadata.originalFiat @@ -972,27 +959,26 @@ fileprivate struct DetailsInfoGrid: InfoGridView { } @ViewBuilder - func offChain_amountSent(_ outgoingPayment: Lightning_kmpOutgoingPayment) -> some View { + func offChain_recipientPubkey( + _ outgoingPayment: Lightning_kmpLightningOutgoingPayment + ) -> some View { let identifier: String = #function InfoGridRowWrapper( identifier: identifier, keyColumnWidth: keyColumnWidth(identifier: identifier) ) { - keyColumn("amount sent") + keyColumn("recipient pubkey") } valueColumn: { - commonValue_amounts(displayAmounts: displayAmounts( - msat: outgoingPayment.amount, - originalFiat: paymentInfo.metadata.originalFiat - )) + Text(outgoingPayment.recipient.value.toHex()) } } @ViewBuilder - func offChain_recipientPubkey( - _ outgoingPayment: Lightning_kmpLightningOutgoingPayment + func onChain_broadcastAt( + _ onChain: Lightning_kmpOnChainOutgoingPayment ) -> some View { let identifier: String = #function @@ -1000,11 +986,11 @@ fileprivate struct DetailsInfoGrid: InfoGridView { identifier: identifier, keyColumnWidth: keyColumnWidth(identifier: identifier) ) { - keyColumn("recipient pubkey") + keyColumn("broadcast at") } valueColumn: { - Text(outgoingPayment.recipient.value.toHex()) + commonValue_date(date: onChain.createdAtDate) } } @@ -1030,7 +1016,7 @@ fileprivate struct DetailsInfoGrid: InfoGridView { } @ViewBuilder - func onChain_claimed( + func onChain_minerFees( _ onChain: Lightning_kmpOnChainOutgoingPayment ) -> some View { let identifier: String = #function @@ -1039,14 +1025,17 @@ fileprivate struct DetailsInfoGrid: InfoGridView { identifier: identifier, keyColumnWidth: keyColumnWidth(identifier: identifier) ) { - keyColumn("claimed amount") + keyColumn("miner fees") } valueColumn: { - commonValue_amounts(displayAmounts: displayAmounts( - sat: onChain.amount.truncateToSatoshi(), - originalFiat: paymentInfo.metadata.originalFiat - )) + commonValue_amounts( + identifier: identifier, + displayAmounts: displayAmounts( + sat: onChain.miningFees, + originalFiat: paymentInfo.metadata.originalFiat + ) + ) } } @@ -1077,6 +1066,76 @@ fileprivate struct DetailsInfoGrid: InfoGridView { } // } + @ViewBuilder + func outgoing_amountSent(_ outgoingPayment: Lightning_kmpOutgoingPayment) -> some View { + let identifier: String = #function + + InfoGridRowWrapper( + identifier: identifier, + keyColumnWidth: keyColumnWidth(identifier: identifier) + ) { + keyColumn("amount sent") + + } valueColumn: { + + commonValue_amounts( + identifier: identifier, + displayAmounts: displayAmounts( + msat: outgoingPayment.amount, + originalFiat: paymentInfo.metadata.originalFiat + ) + ) + } + } + + @ViewBuilder + func amountReceived( + msat: Lightning_kmpMilliSatoshi + ) -> some View { + let identifier: String = #function + + InfoGridRowWrapper( + identifier: identifier, + keyColumnWidth: keyColumnWidth(identifier: identifier) + ) { + keyColumn("amount received") + + } valueColumn: { + + commonValue_amounts( + identifier: identifier, + displayAmounts: displayAmounts( + msat: msat, + originalFiat: paymentInfo.metadata.originalFiat + ) + ) + } + } + + @ViewBuilder + func amountReceived( + sat: Bitcoin_kmpSatoshi + ) -> some View { + let identifier: String = #function + + InfoGridRowWrapper( + identifier: identifier, + keyColumnWidth: keyColumnWidth(identifier: identifier) + ) { + keyColumn("amount received") + + } valueColumn: { + + commonValue_amounts( + identifier: identifier, + displayAmounts: displayAmounts( + sat: sat, + originalFiat: paymentInfo.metadata.originalFiat + ) + ) + } + } + @ViewBuilder func failed_failedAt( _ failed: Lightning_kmpLightningOutgoingPayment.StatusCompletedFailed @@ -1229,6 +1288,7 @@ fileprivate struct DetailsInfoGrid: InfoGridView { @ViewBuilder func commonValue_amounts( + identifier: String, displayAmounts: DisplayAmounts, displayFeePercent: String? = nil ) -> some View { @@ -1261,43 +1321,121 @@ fileprivate struct DetailsInfoGrid: InfoGridView { Text(verbatim: display_feePercent) } - HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + // If we know the original fiat exchange rate, then we can switch back and forth + // between the original & current fiat value. + // + // However, if we do NOT know the original fiat value, + // then we simply show the current fiat value (without the clock button) + // + let canShowOriginalFiatValue = displayAmounts.fiatOriginal != nil + let showingOriginalFiatValue = showOriginalFiatValue && canShowOriginalFiatValue + + // The preferred layout is with a single row: + // 1,234 USD (original) (clock) + // + // However, if the text gets truncated it looks really odd: + // 1,23 USD (origi (clock) + // 4 nal) + // + // In that case we switch to 2 rows. + // Note that if we detect truncation for either (original) or (now), + // then we keep the layout at 2 rows so it's not confusing when the user switches back and forth. + + let key = identifier + let isTruncated = truncatedText[key] ?? false + + if isTruncated { - // If we know the original fiat exchange rate, then we can switch back and forth - // between the original & current fiat value. - // - // However, if we do NOT know the original fiat value, - // then we simply show the current fiat value (without the clock button) - // - let canShowOriginalFiatValue = displayAmounts.fiatOriginal != nil + // Two rows: + // 1,234 USD + // (original) (clock) - if showOriginalFiatValue && canShowOriginalFiatValue { + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + if showingOriginalFiatValue { + + let display_fiatOriginal = displayAmounts.fiatOriginal ?? + Utils.unknownFiatAmount(fiatCurrency: currencyPrefs.fiatCurrency) + + Text(verbatim: "โ‰ˆ \(display_fiatOriginal.digits) ") + Text_CurrencyName(currency: display_fiatOriginal.currency, fontTextStyle: .callout) + + } else { + + let display_fiatCurrent = displayAmounts.fiatCurrent ?? + Utils.unknownFiatAmount(fiatCurrency: currencyPrefs.fiatCurrency) + + Text(verbatim: "โ‰ˆ \(display_fiatCurrent.digits) ") + Text_CurrencyName(currency: display_fiatCurrent.currency, fontTextStyle: .callout) + } + } // + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + if showingOriginalFiatValue { + Text(" (original)").foregroundColor(.secondary) + } else { + Text(" (now)").foregroundColor(.secondary) + } - let display_fiatOriginal = displayAmounts.fiatOriginal ?? - Utils.unknownFiatAmount(fiatCurrency: currencyPrefs.fiatCurrency) + if canShowOriginalFiatValue { + + AnimatedClock(state: clockStateBinding(), size: 14, animationDuration: 0.0) + .padding(.leading, 4) + .offset(y: 1) + } + } // + + } else { + + // Single row: + // 1,234 USD (original) (clock) + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { - Text(verbatim: "โ‰ˆ \(display_fiatOriginal.digits) ") - Text_CurrencyName(currency: display_fiatOriginal.currency, fontTextStyle: .callout) - Text(" (original)").foregroundColor(.secondary) + if showingOriginalFiatValue { + + let display_fiatOriginal = displayAmounts.fiatOriginal ?? + Utils.unknownFiatAmount(fiatCurrency: currencyPrefs.fiatCurrency) + + TruncatableView(fixedHorizontal: true, fixedVertical: true) { + Text(verbatim: "โ‰ˆ \(display_fiatOriginal.digits) ") + .layoutPriority(0) + } wasTruncated: { + truncatedText[key] = true + } + Text_CurrencyName(currency: display_fiatOriginal.currency, fontTextStyle: .callout) + .layoutPriority(1) + Text(" (original)") + .layoutPriority(1) + .foregroundColor(.secondary) + + } else { + + let display_fiatCurrent = displayAmounts.fiatCurrent ?? + Utils.unknownFiatAmount(fiatCurrency: currencyPrefs.fiatCurrency) - } else { + TruncatableView(fixedHorizontal: true, fixedVertical: true) { + Text(verbatim: "โ‰ˆ \(display_fiatCurrent.digits) ") + .layoutPriority(0) + } wasTruncated: { + truncatedText[key] = true + } + Text_CurrencyName(currency: display_fiatCurrent.currency, fontTextStyle: .callout) + .layoutPriority(1) + Text(" (now)") + .layoutPriority(1) + .foregroundColor(.secondary) + } - let display_fiatCurrent = displayAmounts.fiatCurrent ?? - Utils.unknownFiatAmount(fiatCurrency: currencyPrefs.fiatCurrency) - - Text(verbatim: "โ‰ˆ \(display_fiatCurrent.digits) ") - Text_CurrencyName(currency: display_fiatCurrent.currency, fontTextStyle: .callout) - Text(" (now)").foregroundColor(.secondary) - } - - if canShowOriginalFiatValue { + if canShowOriginalFiatValue { + + AnimatedClock(state: clockStateBinding(), size: 14, animationDuration: 0.0) + .padding(.leading, 4) + .offset(y: 1) + } - AnimatedClock(state: clockStateBinding(), size: 14, animationDuration: 0.0) - .padding(.leading, 4) - .offset(y: 1) - } - - } // + } // + } + } // } @@ -1479,85 +1617,85 @@ fileprivate struct DetailsInfoGrid: InfoGridView { UIApplication.shared.open(txUrl) } } +} + +// -------------------------------------------------- +// MARK: - +// -------------------------------------------------- + +fileprivate struct DisplayAmounts { + let bitcoin: FormattedAmount + let fiatCurrent: FormattedAmount? + let fiatOriginal: FormattedAmount? +} + +fileprivate struct InlineSection: View { - // -------------------------------------------------- - // MARK: Helpers - // -------------------------------------------------- + let header: Header + let content: Content - struct DisplayAmounts { - let bitcoin: FormattedAmount - let fiatCurrent: FormattedAmount? - let fiatOriginal: FormattedAmount? + init( + @ViewBuilder header headerBuilder: () -> Header, + @ViewBuilder content contentBuilder: () -> Content + ) { + header = headerBuilder() + content = contentBuilder() } - struct InlineSection: View { - - let header: Header - let content: Content - - init( - @ViewBuilder header headerBuilder: () -> Header, - @ViewBuilder content contentBuilder: () -> Content - ) { - header = headerBuilder() - content = contentBuilder() - } + @ViewBuilder + var body: some View { - @ViewBuilder - var body: some View { - - VStack(alignment: HorizontalAlignment.center, spacing: 0) { - header - HStack(alignment: VerticalAlignment.center, spacing: 0) { - VStack(alignment: HorizontalAlignment.leading, spacing: 12) { - content - } - Spacer(minLength: 0) - } - .padding(.vertical, 10) - .padding(.horizontal, 16) - .background { - Color.white.cornerRadius(10) + VStack(alignment: HorizontalAlignment.center, spacing: 0) { + header + HStack(alignment: VerticalAlignment.center, spacing: 0) { + VStack(alignment: HorizontalAlignment.leading, spacing: 12) { + content } - .padding(.horizontal, 16) + Spacer(minLength: 0) + } + .padding(.vertical, 10) + .padding(.horizontal, 16) + .background { + Color.white.cornerRadius(10) } - .padding(.vertical, 16) + .padding(.horizontal, 16) } + .padding(.vertical, 16) + } +} + +fileprivate struct InfoGridRowWrapper: View { + + let identifier: String + let keyColumnWidth: CGFloat + let keyColumn: KeyColumn + let valueColumn: ValueColumn + + init( + identifier: String, + keyColumnWidth: CGFloat, + @ViewBuilder keyColumn keyColumnBuilder: () -> KeyColumn, + @ViewBuilder valueColumn valueColumnBuilder: () -> ValueColumn + ) { + self.identifier = identifier + self.keyColumnWidth = keyColumnWidth + self.keyColumn = keyColumnBuilder() + self.valueColumn = valueColumnBuilder() } - struct InfoGridRowWrapper: View { - - let identifier: String - let keyColumnWidth: CGFloat - let keyColumn: KeyColumn - let valueColumn: ValueColumn + @ViewBuilder + var body: some View { - init( - identifier: String, - keyColumnWidth: CGFloat, - @ViewBuilder keyColumn keyColumnBuilder: () -> KeyColumn, - @ViewBuilder valueColumn valueColumnBuilder: () -> ValueColumn + InfoGridRow( + identifier: identifier, + vAlignment: .firstTextBaseline, + hSpacing: 8, + keyColumnWidth: keyColumnWidth, + keyColumnAlignment: .trailing ) { - self.identifier = identifier - self.keyColumnWidth = keyColumnWidth - self.keyColumn = keyColumnBuilder() - self.valueColumn = valueColumnBuilder() - } - - @ViewBuilder - var body: some View { - - InfoGridRow( - identifier: identifier, - vAlignment: .firstTextBaseline, - hSpacing: 8, - keyColumnWidth: keyColumnWidth, - keyColumnAlignment: .trailing - ) { - keyColumn - } valueColumn: { - valueColumn.font(.callout) - } + keyColumn + } valueColumn: { + valueColumn.font(.callout) } } } diff --git a/phoenix-ios/phoenix-ios/views/inspect/EditInfoView.swift b/phoenix-ios/phoenix-ios/views/inspect/EditInfoView.swift index 409dc769a..6324f6d78 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/EditInfoView.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/EditInfoView.swift @@ -209,10 +209,11 @@ struct EditInfoView: View { HStack(alignment: .center, spacing: 4) { Image(systemName: "chevron.backward") .imageScale(.medium) + .font(.title3.weight(.semibold)) Text("Save") + .font(.title3) } } - .font(.title3.weight(.semibold)) } // -------------------------------------------------- diff --git a/phoenix-ios/phoenix-ios/views/inspect/PaymentView.swift b/phoenix-ios/phoenix-ios/views/inspect/PaymentView.swift index dff7ade5f..16e5cb296 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/PaymentView.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/PaymentView.swift @@ -21,10 +21,10 @@ enum PaymentViewType { /// - thus we cannot use the general API case sheet(closeAction: () -> Void) - case embedded + case embedded(popTo: (PopToDestination) -> Void) } -struct PaymentView : View { +struct PaymentView: View { let type: PaymentViewType let paymentInfo: WalletPaymentInfo @@ -34,13 +34,61 @@ struct PaymentView : View { switch type { case .sheet: + PaymentViewSheet(type: type, paymentInfo: paymentInfo) + + case .embedded: + SummaryView(type: type, paymentInfo: paymentInfo) + } + } +} + +fileprivate struct PaymentViewSheet: View { + + let type: PaymentViewType + let paymentInfo: WalletPaymentInfo + + @EnvironmentObject var shortSheetState: ShortSheetState + @State var shortSheetItem: ShortSheetItem? = nil + + @EnvironmentObject var popoverState: PopoverState + @State var popoverItem: PopoverItem? = nil + + @ViewBuilder + var body: some View { + + ZStack { + NavigationWrapper { SummaryView(type: type, paymentInfo: paymentInfo) } .edgesIgnoringSafeArea(.all) + .zIndex(0) // needed for proper animation + .accessibilityHidden(shortSheetItem != nil || popoverItem != nil) - case .embedded: - SummaryView(type: type, paymentInfo: paymentInfo) + if let shortSheetItem = shortSheetItem { + ShortSheetWrapper(dismissable: shortSheetItem.dismissable) { + shortSheetItem.view + } + .zIndex(1) // needed for proper animation + } + + if let popoverItem = popoverItem { + PopoverWrapper(dismissable: popoverItem.dismissable) { + popoverItem.view + } + .zIndex(2) // needed for proper animation + } + + } // + .onReceive(shortSheetState.publisher) { (item: ShortSheetItem?) in + withAnimation { + shortSheetItem = item + } + } + .onReceive(popoverState.publisher) { (item: PopoverItem?) in + withAnimation { + popoverItem = item + } } } } diff --git a/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift b/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift index 6fd1f5187..d24627909 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift @@ -3,7 +3,7 @@ import PhoenixShared import Popovers import os.log -#if DEBUG && false +#if DEBUG && true fileprivate var log = Logger( subsystem: Bundle.main.bundleIdentifier!, category: "SummaryView" @@ -21,17 +21,22 @@ struct SummaryView: View { let fetchOptions = WalletPaymentFetchOptions.companion.All + @State var blockchainConfirmations: Int? = nil + @State var showBlockchainExplorerOptions = false + @State var showOriginalFiatValue = GlobalEnvironment.currencyPrefs.showOriginalFiatValue @State var showFiatValueExplanation = false @State var showDeletePaymentConfirmationDialog = false @State var didAppear = false - - @EnvironmentObject var currencyPrefs: CurrencyPrefs + @State var popToDestination: PopToDestination? = nil @Environment(\.presentationMode) var presentationMode: Binding + @EnvironmentObject var currencyPrefs: CurrencyPrefs + @EnvironmentObject var smartModalState: SmartModalState + enum ButtonWidth: Preference {} let buttonWidthReader = GeometryPreferenceReader( key: AppendValue.self, @@ -120,9 +125,7 @@ struct SummaryView: View { Button { closeAction() } label: { - Image("ic_cross") - .resizable() - .frame(width: 30, height: 30) + Image(systemName: "xmark").imageScale(.medium).font(.title2) } .padding() .accessibilityLabel("Close sheet") @@ -136,6 +139,9 @@ struct SummaryView: View { .onAppear { onAppear() } + .task { + await monitorBlockchain() + } } @ViewBuilder @@ -181,7 +187,10 @@ struct SummaryView: View { .font(Font.title2.bold()) .padding(.bottom, 2) - if let completedAtDate = payment.completedAtDate { + if let onChainPayment = payment as? Lightning_kmpOnChainOutgoingPayment { + header_blockchainStatus(onChainPayment) + + } else if let completedAtDate = payment.completedAtDate { Text(completedAtDate.format()) .font(.subheadline) .foregroundColor(.secondary) @@ -205,23 +214,11 @@ struct SummaryView: View { .padding(.bottom, 6) .accessibilityLabel("Pending payment") .accessibilityHint("Waiting for confirmations") - if let depth = minFundingDepth() { - let minutes = depth * 10 - Text("requires \(depth) confirmations") - .font(.footnote) - .multilineTextAlignment(.center) - .foregroundColor(.secondary) - Text("โ‰ˆ\(minutes) minutes") - .font(.footnote) - .multilineTextAlignment(.center) - .foregroundColor(.secondary) - } - if let broadcastDate = onChainBroadcastDate() { - Text(broadcastDate.format()) - .font(.subheadline) - .foregroundColor(.secondary) - .padding(.top, 12) + + if let onChainPayment = payment as? Lightning_kmpOnChainOutgoingPayment { + header_blockchainStatus(onChainPayment) } + } // .padding(.bottom, 30) @@ -272,6 +269,100 @@ struct SummaryView: View { } } + @ViewBuilder + func header_blockchainStatus(_ onChainPayment: Lightning_kmpOnChainOutgoingPayment) -> some View { + + switch blockchainConfirmations { + case .none: + + HStack(alignment: VerticalAlignment.center, spacing: 4) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: Color.secondary)) + + Text("Checking blockchainโ€ฆ") + .font(.callout) + .foregroundColor(.secondary) + } + .padding(.top, 10) + + case .some(let confirmations): + + VStack(alignment: HorizontalAlignment.center, spacing: 0) { + + Button { + showBlockchainExplorerOptions = true + } label: { + if confirmations == 1 { + Text("1 confirmation") + .font(.subheadline) + } else if confirmations < 7 { + Text("\(confirmations) confirmations") + .font(.subheadline) + } else { + Text("6+ confirmations") + .font(.subheadline) + } + } + .confirmationDialog("Blockchain Explorer", + isPresented: $showBlockchainExplorerOptions, + titleVisibility: .automatic + ) { + Button { + exploreTx(onChainPayment.txId, website: BlockchainExplorer.WebsiteMempoolSpace()) + } label: { + Text(verbatim: "Mempool.space") // no localization needed + } + Button { + exploreTx(onChainPayment.txId, website: BlockchainExplorer.WebsiteBlockstreamInfo()) + } label: { + Text(verbatim: "Blockstream.info") // no localization needed + } + Button("Copy transaction id") { + copyTxId(onChainPayment.txId) + } + } // + + if confirmations == 0 { + NavigationLink(destination: cpfpView(onChainPayment)) { + Label { + Text("Accelerate transaction") + } icon: { + Image(systemName: "paperplane").imageScale(.small) + } + .font(.subheadline) + } + .padding(.top, 3) + } + + if let confirmedAt = onChainPayment.confirmedAt?.int64Value.toDate(from: .milliseconds) { + + Text("confirmed") + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.top, 20) + + Text(confirmedAt.format()) + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.top, 3) + + } else { + + Text("broadcast") + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.top, 20) + + Text(onChainPayment.createdAt.toDate(from: .milliseconds).format()) + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.top, 3) + } + } + .padding(.top, 10) + } + } + @ViewBuilder func header_amount() -> some View { @@ -395,10 +486,14 @@ struct SummaryView: View { HStack(alignment: VerticalAlignment.center, spacing: 16) { NavigationLink(destination: detailsView()) { - Text("Details") - .frame(minWidth: buttonWidth, alignment: Alignment.trailing) - .read(buttonWidthReader) - .read(buttonHeightReader) + Label { + Text("Details") + } icon: { + Image(systemName: "magnifyingglass").imageScale(.small) + } + .frame(minWidth: buttonWidth, alignment: Alignment.trailing) + .read(buttonWidthReader) + .read(buttonHeightReader) } if let buttonHeight = buttonHeight { @@ -406,10 +501,14 @@ struct SummaryView: View { } NavigationLink(destination: editInfoView()) { - Text("Edit") - .frame(minWidth: buttonWidth, alignment: Alignment.leading) - .read(buttonWidthReader) - .read(buttonHeightReader) + Label { + Text("Edit") + } icon: { + Image(systemName: "pencil.line").imageScale(.small) + } + .frame(minWidth: buttonWidth, alignment: Alignment.leading) + .read(buttonWidthReader) + .read(buttonHeightReader) } } .padding([.top, .bottom]) @@ -425,10 +524,14 @@ struct SummaryView: View { HStack(alignment: VerticalAlignment.center, spacing: 16) { NavigationLink(destination: detailsView()) { - Text("Details") - .frame(minWidth: buttonWidth, alignment: Alignment.trailing) - .read(buttonWidthReader) - .read(buttonHeightReader) + Label { + Text("Details") + } icon: { + Image(systemName: "magnifyingglass").imageScale(.small) + } + .frame(minWidth: buttonWidth, alignment: Alignment.trailing) + .read(buttonWidthReader) + .read(buttonHeightReader) } if let buttonHeight = buttonHeight { @@ -436,10 +539,14 @@ struct SummaryView: View { } NavigationLink(destination: editInfoView()) { - Text("Edit") - .frame(minWidth: buttonWidth, alignment: Alignment.center) - .read(buttonWidthReader) - .read(buttonHeightReader) + Label { + Text("Edit") + } icon: { + Image(systemName: "pencil.line").imageScale(.small) + } + .frame(minWidth: buttonWidth, alignment: Alignment.center) + .read(buttonWidthReader) + .read(buttonHeightReader) } if let buttonHeight = buttonHeight { @@ -449,11 +556,15 @@ struct SummaryView: View { Button { showDeletePaymentConfirmationDialog = true } label: { - Text("Delete") - .foregroundColor(.appNegative) - .frame(minWidth: buttonWidth, alignment: Alignment.leading) - .read(buttonWidthReader) - .read(buttonHeightReader) + Label { + Text("Delete") + } icon: { + Image(systemName: "eraser.line.dashed").imageScale(.small) + } + .foregroundColor(.appNegative) + .frame(minWidth: buttonWidth, alignment: Alignment.leading) + .read(buttonWidthReader) + .read(buttonHeightReader) } .confirmationDialog("Delete payment?", isPresented: $showDeletePaymentConfirmationDialog, @@ -472,7 +583,7 @@ struct SummaryView: View { @ViewBuilder func detailsView() -> some View { DetailsView( - type: type, + type: wrappedType(), paymentInfo: $paymentInfo, showOriginalFiatValue: $showOriginalFiatValue, showFiatValueExplanation: $showFiatValueExplanation @@ -482,44 +593,23 @@ struct SummaryView: View { @ViewBuilder func editInfoView() -> some View { EditInfoView( - type: type, + type: wrappedType(), paymentInfo: $paymentInfo ) } + @ViewBuilder + func cpfpView(_ onChainPayment: Lightning_kmpOnChainOutgoingPayment) -> some View { + CpfpView( + type: wrappedType(), + onChainPayment: onChainPayment + ) + } + // -------------------------------------------------- // MARK: View Helpers // -------------------------------------------------- - func minFundingDepth() -> Int32? { - - guard - let incomingPayment = paymentInfo.payment as? Lightning_kmpIncomingPayment, - let received = incomingPayment.received, - let newChannel = received.receivedWith.compactMap({ $0.asNewChannel() }).first, - let channel = Biz.business.peerManager.getChannelWithCommitments(channelId: newChannel.channelId), - let nodeParams = Biz.business.nodeParamsManager.nodeParams.value_ as? Lightning_kmpNodeParams - else { - return nil - } - - return channel.minDepthForFunding(nodeParams: nodeParams) - } - - func onChainBroadcastDate() -> Date? { - - if let incomingPayment = paymentInfo.payment as? Lightning_kmpIncomingPayment { - - if let _ = incomingPayment.origin.asOnChain() { - if let received = incomingPayment.received { - return received.receivedAtDate - } - } - } - - return nil - } - func formattedAmount() -> FormattedAmount { let msat = paymentInfo.payment.amount @@ -549,6 +639,91 @@ struct SummaryView: View { } } + func wrappedType() -> PaymentViewType { + + switch type { + case .sheet(_): + return type + case .embedded(_): + return .embedded(popTo: popToWrapper) + } + } + + // -------------------------------------------------- + // MARK: Tasks + // -------------------------------------------------- + + func updateConfirmations(_ onChainPayment: Lightning_kmpOnChainOutgoingPayment) async -> Int { + log.trace("checkConfirmations()") + + do { + let result = try await Biz.business.electrumClient.kotlin_getConfirmations(txid: onChainPayment.txId) + + let confirmations = result?.intValue ?? 0 + log.debug("checkConfirmations(): => \(confirmations)") + + self.blockchainConfirmations = confirmations + return confirmations + + } catch { + log.error("checkConfirmations(): error: \(error)") + return 0 + } + } + + func monitorBlockchain() async { + log.trace("monitorBlockchain()") + + guard let onChainPayment = paymentInfo.payment as? Lightning_kmpOnChainOutgoingPayment else { + log.debug("monitorBlockchain(): not an on-chain payment") + return + } + + if let confirmedAtDate = onChainPayment.confirmedAtDate { + let elapsed = confirmedAtDate.timeIntervalSinceNow * -1.0 + if elapsed > 24.hours() { + // It was marked as mined more than 24 hours ago. + // So there's really no need to check the exact confirmation count anymore. + log.debug("monitorBlockchain(): confirmedAt > 24.hours.ago") + self.blockchainConfirmations = 7 + return + } + } + + let confirmations = await updateConfirmations(onChainPayment) + if confirmations > 6 { + // No need to continue checking confirmation count, + // because the UI displays "6+" from this point forward. + log.debug("monitorBlockchain(): confirmations > 6") + return + } + + for await notification in Biz.business.electrumClient.notificationsPublisher().values { + + if notification is Lightning_kmpHeaderSubscriptionResponse { + // A new block was mined ! + // Update confirmation count if needed. + let confirmations = await updateConfirmations(onChainPayment) + if confirmations > 6 { + // No need to continue checking confirmation count, + // because the UI displays "6+" from this point forward. + log.debug("monitorBlockchain(): confirmations > 6") + break + } + + } else { + log.debug("monitorBlockchain(): notification isNot HeaderSubscriptionResponse") + } + + if Task.isCancelled { + log.debug("monitorBlockchain(): Task.isCancelled") + break + } else { + log.debug("monitorBlockchain(): Waiting for next electrum notification...") + } + } + } + // -------------------------------------------------- // MARK: Notifications // -------------------------------------------------- @@ -600,6 +775,22 @@ struct SummaryView: View { paymentInfo = result } } + + if let destination = popToDestination { + log.debug("popToDestination: \(destination)") + + popToDestination = nil + switch destination { + case .RootView(_): + log.debug("Unhandled popToDestination") + + case .ConfigurationView(_): + log.debug("Unhandled popToDestination") + + case .TransactionsView: + presentationMode.wrappedValue.dismiss() + } + } } } @@ -607,6 +798,31 @@ struct SummaryView: View { // MARK: Actions // -------------------------------------------------- + func popToWrapper(_ destination: PopToDestination) { + log.trace("popToWrapper(\(destination))") + + popToDestination = destination + if case .embedded(let popTo) = type { + popTo(destination) + } + } + + func exploreTx(_ txId: Bitcoin_kmpByteVector32, website: BlockchainExplorer.Website) { + log.trace("exploreTX()") + + let txIdStr = txId.toHex() + let txUrlStr = Biz.business.blockchainExplorer.txUrl(txId: txIdStr, website: website) + if let txUrl = URL(string: txUrlStr) { + UIApplication.shared.open(txUrl) + } + } + + func copyTxId(_ txId: Bitcoin_kmpByteVector32) { + log.trace("copyTxId()") + + UIPasteboard.general.string = txId.toHex() + } + func toggleCurrencyType() -> Void { currencyPrefs.toggleCurrencyType() } diff --git a/phoenix-ios/phoenix-ios/views/widgets/Popover.swift b/phoenix-ios/phoenix-ios/views/layers/Popover.swift similarity index 87% rename from phoenix-ios/phoenix-ios/views/widgets/Popover.swift rename to phoenix-ios/phoenix-ios/views/layers/Popover.swift index 8842e90cb..ff960e758 100644 --- a/phoenix-ios/phoenix-ios/views/widgets/Popover.swift +++ b/phoenix-ios/phoenix-ios/views/layers/Popover.swift @@ -1,9 +1,9 @@ import SwiftUI import Combine -/// The PopoverState is exposed via an Environment variable: +/// The PopoverState is exposed via an EnvironmentObject variable: /// ``` -/// @Environment(\.popoverState) var popoverState: PopoverState +/// @EnvironmentObject var popoverState: PopoverState /// ``` /// /// When you want to display a popover: @@ -19,12 +19,6 @@ import Combine /// ``` /// public class PopoverState: ObservableObject { - - /// Singleton instance - /// - public static let shared = PopoverState() - - private init() {/* must use shared instance */} /// Fires when: /// - view will animate on screen (onWillAppear) @@ -116,19 +110,6 @@ public struct PopoverItem: SmartModalItem { let view: AnyView } -struct PopoverEnvironmentKey: EnvironmentKey { - - static var defaultValue = PopoverState.shared -} - -public extension EnvironmentValues { - - var popoverState: PopoverState { - get { self[PopoverEnvironmentKey.self] } - set { self[PopoverEnvironmentKey.self] = newValue } - } -} - struct PopoverWrapper: View { let dismissable: Bool @@ -136,7 +117,7 @@ struct PopoverWrapper: View { @State var animation: CGFloat = 0.0 - @Environment(\.popoverState) private var popoverState: PopoverState + @EnvironmentObject var popoverState: PopoverState var body: some View { diff --git a/phoenix-ios/phoenix-ios/views/widgets/ShortSheet.swift b/phoenix-ios/phoenix-ios/views/layers/ShortSheet.swift similarity index 85% rename from phoenix-ios/phoenix-ios/views/widgets/ShortSheet.swift rename to phoenix-ios/phoenix-ios/views/layers/ShortSheet.swift index d6c2d473e..3b12cd09e 100644 --- a/phoenix-ios/phoenix-ios/views/widgets/ShortSheet.swift +++ b/phoenix-ios/phoenix-ios/views/layers/ShortSheet.swift @@ -1,9 +1,9 @@ import SwiftUI import Combine -/// The ShortSheetState is exposed via an Environment variable: +/// The ShortSheetState is exposed via an EnvironmentObject variable: /// ``` -/// @Environment(\.shortSheetState) var shortSheetState: ShortSheetState +/// @EnvironmentObject var shortSheetState: ShortSheetState /// ``` /// /// When you want to display a short sheet: @@ -19,12 +19,6 @@ import Combine /// ``` /// public class ShortSheetState: ObservableObject { - - /// Singleton instance - /// - public static let shared = ShortSheetState() - - private init() {/* must use shared instance */} /// Fires when: /// - sheet view will animate on screen (onWillAppear) @@ -109,19 +103,6 @@ public struct ShortSheetItem: SmartModalItem { let view: AnyView } -struct ShortSheetEnvironmentKey: EnvironmentKey { - - static var defaultValue = ShortSheetState.shared -} - -public extension EnvironmentValues { - - var shortSheetState: ShortSheetState { - get { self[ShortSheetEnvironmentKey.self] } - set { self[ShortSheetEnvironmentKey.self] = newValue } - } -} - struct ShortSheetWrapper: View { let dismissable: Bool @@ -129,7 +110,7 @@ struct ShortSheetWrapper: View { @State var animation: CGFloat = 0.0 - @Environment(\.shortSheetState) private var shortSheetState: ShortSheetState + @EnvironmentObject var shortSheetState: ShortSheetState var body: some View { diff --git a/phoenix-ios/phoenix-ios/views/widgets/SmartModal.swift b/phoenix-ios/phoenix-ios/views/layers/SmartModal.swift similarity index 55% rename from phoenix-ios/phoenix-ios/views/widgets/SmartModal.swift rename to phoenix-ios/phoenix-ios/views/layers/SmartModal.swift index a7cbf1f68..d1ad63080 100644 --- a/phoenix-ios/phoenix-ios/views/widgets/SmartModal.swift +++ b/phoenix-ios/phoenix-ios/views/layers/SmartModal.swift @@ -4,9 +4,9 @@ import SwiftUI /// - iPhone => ShortSheet /// - iPad => Popover /// -/// The SmartModalState is exposed via an Environment variable: +/// The SmartModalState is exposed via an EnvironmentObject variable: /// ``` -/// @Environment(\.smartModalState) var smartModalState: SmartModalState +/// @EnvironmentObject var smartModalState: SmartModalState /// ``` /// /// When you want to display a modeal: @@ -23,11 +23,13 @@ import SwiftUI /// public class SmartModalState: ObservableObject { - /// Singleton instance - /// - public static let shared = SmartModalState() + private let popoverState: PopoverState + private let shortSheetState: ShortSheetState - private init() {/* must use shared instance */} + init(popoverState: PopoverState, shortSheetState: ShortSheetState) { + self.popoverState = popoverState + self.shortSheetState = shortSheetState + } private var isIPad: Bool { return UIDevice.current.userInterfaceIdiom == .pad @@ -36,9 +38,9 @@ public class SmartModalState: ObservableObject { var currentItem: SmartModalItem? { if isIPad { - return PopoverState.shared.publisher.value + return popoverState.publisher.value } else { - return ShortSheetState.shared.publisher.value + return shortSheetState.publisher.value } } @@ -48,13 +50,13 @@ public class SmartModalState: ObservableObject { onWillDisappear: (() -> Void)? = nil ) { if isIPad { - PopoverState.shared.display( + popoverState.display( dismissable: dismissable, builder: builder, onWillDisappear: onWillDisappear ) } else { - ShortSheetState.shared.display( + shortSheetState.display( dismissable: dismissable, builder: builder, onWillDisappear: onWillDisappear @@ -64,33 +66,33 @@ public class SmartModalState: ObservableObject { func close() { if isIPad { - PopoverState.shared.close() + popoverState.close() } else { - ShortSheetState.shared.close() + shortSheetState.close() } } func close(animationCompletion: @escaping () -> Void) { if isIPad { - PopoverState.shared.close(animationCompletion: animationCompletion) + popoverState.close(animationCompletion: animationCompletion) } else { - ShortSheetState.shared.close(animationCompletion: animationCompletion) + shortSheetState.close(animationCompletion: animationCompletion) } } func onNextWillDisappear(_ action: @escaping () -> Void) { if isIPad { - PopoverState.shared.onNextWillDisappear(action) + popoverState.onNextWillDisappear(action) } else { - ShortSheetState.shared.onNextWillDisappear(action) + shortSheetState.onNextWillDisappear(action) } } func onNextDidDisappear(_ action: @escaping () -> Void) { if isIPad { - PopoverState.shared.onNextDidDisappear(action) + popoverState.onNextDidDisappear(action) } else { - ShortSheetState.shared.onNextDidDisappear(action) + shortSheetState.onNextDidDisappear(action) } } } @@ -100,16 +102,3 @@ protocol SmartModalItem { /// Whether or not the item is dimissable by tapping outside the item's view. var dismissable: Bool { get } } - -struct SmartModalEnvironmentKey: EnvironmentKey { - - static var defaultValue = SmartModalState.shared -} - -public extension EnvironmentValues { - - var smartModalState: SmartModalState { - get { self[SmartModalEnvironmentKey.self] } - set { self[SmartModalEnvironmentKey.self] = newValue } - } -} diff --git a/phoenix-ios/phoenix-ios/views/widgets/Toast.swift b/phoenix-ios/phoenix-ios/views/layers/Toast.swift similarity index 100% rename from phoenix-ios/phoenix-ios/views/widgets/Toast.swift rename to phoenix-ios/phoenix-ios/views/layers/Toast.swift diff --git a/phoenix-ios/phoenix-ios/views/main/AppStatusButton.swift b/phoenix-ios/phoenix-ios/views/main/AppStatusButton.swift index 3aad3bf3d..2390276d6 100644 --- a/phoenix-ios/phoenix-ios/views/main/AppStatusButton.swift +++ b/phoenix-ios/phoenix-ios/views/main/AppStatusButton.swift @@ -28,8 +28,7 @@ struct AppStatusButton: View { @StateObject var connectionsMonitor = ObservableConnectionsMonitor() - @Environment(\.popoverState) var popoverState: PopoverState - + @EnvironmentObject var popoverState: PopoverState @EnvironmentObject var deviceInfo: DeviceInfo let syncTxManager = Biz.syncManager!.syncTxManager diff --git a/phoenix-ios/phoenix-ios/views/main/BgRefreshDisabledPopover.swift b/phoenix-ios/phoenix-ios/views/main/BgRefreshDisabledPopover.swift index 64e9ea0ed..b8d22f1ef 100644 --- a/phoenix-ios/phoenix-ios/views/main/BgRefreshDisabledPopover.swift +++ b/phoenix-ios/phoenix-ios/views/main/BgRefreshDisabledPopover.swift @@ -13,7 +13,7 @@ fileprivate var log = Logger(OSLog.disabled) struct BgRefreshDisabledPopover: View { - @Environment(\.popoverState) var popoverState: PopoverState + @EnvironmentObject var popoverState: PopoverState @ViewBuilder var body: some View { diff --git a/phoenix-ios/phoenix-ios/views/main/HomeView.swift b/phoenix-ios/phoenix-ios/views/main/HomeView.swift index a4e5c9723..b9c527d5a 100644 --- a/phoenix-ios/phoenix-ios/views/main/HomeView.swift +++ b/phoenix-ios/phoenix-ios/views/main/HomeView.swift @@ -24,6 +24,8 @@ struct HomeView : MVIView { private let paymentsManager = Biz.business.paymentsManager private let paymentsPageFetcher = Biz.getPaymentsPageFetcher(name: "HomeView") + let showSwapInWallet: () -> Void + @StateObject var mvi = MVIState({ $0.home() }) @Environment(\.controllerFactory) var factoryEnv @@ -32,10 +34,6 @@ struct HomeView : MVIView { @StateObject var noticeMonitor = NoticeMonitor() @StateObject var syncState = DownloadMonitor() - @EnvironmentObject var deviceInfo: DeviceInfo - @EnvironmentObject var currencyPrefs: CurrencyPrefs - @EnvironmentObject var deepLinkManager: DeepLinkManager - let recentPaymentsConfigPublisher = Prefs.shared.recentPaymentsConfigPublisher @State var recentPaymentsConfig = Prefs.shared.recentPaymentsConfig @@ -44,8 +42,8 @@ struct HomeView : MVIView { let lastCompletedPaymentPublisher = Biz.business.paymentsManager.lastCompletedPaymentPublisher() - let swapInWalletBalancePublisher = Biz.business.balanceManager.swapInWalletBalancePublisher() - @State var swapInWalletBalance: WalletBalance = WalletBalance.companion.empty() + let swapInWalletPublisher = Biz.business.balanceManager.swapInWalletPublisher() + @State var swapInWallet = Biz.business.balanceManager.swapInWalletValue() let swapInRejectedPublisher = Biz.swapInRejectedPublisher @State var swapInRejected: Lightning_kmpLiquidityEventsRejected? = nil @@ -54,9 +52,6 @@ struct HomeView : MVIView { @State var incomingSwapScaleFactor: CGFloat = 1.0 @State var incomingSwapAnimationsRemaining = 0 - // Toggles confirmation dialog (used to select preferred explorer) - @State var showBlockchainExplorerOptions = false - let bizNotificationsPublisher = Biz.business.notificationsManager.notificationsPublisher() @State var bizNotifications: [PhoenixShared.NotificationsManager.NotificationItem] = [] @@ -76,16 +71,21 @@ struct HomeView : MVIView { @State var activeSheet: HomeViewSheet? = nil - @Environment(\.popoverState) var popoverState: PopoverState @Environment(\.openURL) var openURL @Environment(\.colorScheme) var colorScheme + @EnvironmentObject var deviceInfo: DeviceInfo + @EnvironmentObject var popoverState: PopoverState + @EnvironmentObject var currencyPrefs: CurrencyPrefs + @EnvironmentObject var deepLinkManager: DeepLinkManager + // -------------------------------------------------- // MARK: Init // -------------------------------------------------- - init() { - paymentsPagePublisher = paymentsPageFetcher.paymentsPagePublisher() + init(showSwapInWallet: @escaping () -> Void) { + self.showSwapInWallet = showSwapInWallet + self.paymentsPagePublisher = paymentsPageFetcher.paymentsPagePublisher() } // -------------------------------------------------- @@ -122,8 +122,8 @@ struct HomeView : MVIView { .onReceive(lastCompletedPaymentPublisher) { lastCompletedPaymentChanged($0) } - .onReceive(swapInWalletBalancePublisher) { - swapInWalletBalanceChanged($0) + .onReceive(swapInWalletPublisher) { + swapInWalletChanged($0) } .onReceive(swapInRejectedPublisher) { swapInRejectedStateChange($0) @@ -137,7 +137,6 @@ struct HomeView : MVIView { func content() -> some View { VStack(alignment: HorizontalAlignment.center, spacing: 0) { - balance() .padding(.bottom, 25) notices() @@ -157,14 +156,14 @@ struct HomeView : MVIView { type: .sheet(closeAction: { self.activeSheet = nil }), paymentInfo: selectedPayment ) - .modifier(GlobalEnvironment()) // SwiftUI bug (prevent crash) + .modifier(GlobalEnvironment.sheetInstance()) case .notificationsView: NotificationsView( noticeMonitor: noticeMonitor ) - .modifier(GlobalEnvironment()) // SwiftUI bug (prevent crash) + .modifier(GlobalEnvironment.sheetInstance()) } } } @@ -276,7 +275,7 @@ struct HomeView : MVIView { @ViewBuilder func incomingBalance() -> some View { - let incomingSat = swapInWalletBalance.total.sat + let incomingSat = swapInWallet.totalBalance.sat if incomingSat > 0 { let formattedAmount = currencyPrefs.hideAmounts ? Utils.hiddenAmount(currencyPrefs) @@ -302,7 +301,7 @@ struct HomeView : MVIView { } .font(.callout) .foregroundColor(.secondary) - .onTapGesture { showIncomingDepositPopover() } + .onTapGesture { showSwapInWallet() } .padding(.top, 7) .padding(.bottom, 2) .scaleEffect(incomingSwapScaleFactor, anchor: .top) @@ -318,12 +317,16 @@ struct HomeView : MVIView { @ViewBuilder func notices() -> some View { - let count = noticeCount() - if count > 1 { - notice_multiple(count: count) - } else if count == 1 { - notice_single() + Group { + let count = noticeCount() + if count > 1 { + notice_multiple(count: count) + } else if count == 1 { + notice_single() + } } + .frame(maxWidth: deviceInfo.textColumnMaxWidth) + .padding([.leading, .trailing, .bottom], 10) } @ViewBuilder @@ -370,7 +373,7 @@ struct HomeView : MVIView { .onTapGesture { openNotificationsSheet() } - .padding([.leading, .trailing, .bottom], 10) + } @ViewBuilder @@ -379,7 +382,6 @@ struct HomeView : MVIView { NoticeBox { notice_primary(includeAction: true) } - .padding([.leading, .trailing, .bottom], 10) } @ViewBuilder @@ -606,7 +608,7 @@ struct HomeView : MVIView { log.trace("onModelChange()") let balance = model.balance?.msat ?? 0 - let incomingBalance = swapInWalletBalance.total.sat + let incomingBalance = swapInWallet.totalBalance.sat if balance > 0 || incomingBalance > 0 || model.paymentsCount > 0 { if Prefs.shared.isNewWallet { @@ -653,13 +655,13 @@ struct HomeView : MVIView { } } - func swapInWalletBalanceChanged(_ walletBalance: WalletBalance) { - log.trace("swapInWalletBalanceChanged()") + func swapInWalletChanged(_ newWallet: Lightning_kmpWalletState.WalletWithConfirmations) { + log.trace("swapInWalletChanged()") - let oldBalance = swapInWalletBalance.total.sat - let newBalance = walletBalance.total.sat + let oldBalance = swapInWallet.totalBalance.sat + let newBalance = newWallet.totalBalance.sat - swapInWalletBalance = walletBalance + swapInWallet = newWallet if newBalance > oldBalance { // Since the balance increased, there is a new utxo for the user. // This isn't added to the transaction list, but is instead displayed under the balance. @@ -755,14 +757,6 @@ struct HomeView : MVIView { } } - func showIncomingDepositPopover() { - log.trace("showIncomingDepositPopover()") - - popoverState.display(dismissable: true) { - IncomingDepositPopover() - } - } - func openNotificationsSheet() { log.trace("openNotificationSheet()") diff --git a/phoenix-ios/phoenix-ios/views/main/MainView_Big.swift b/phoenix-ios/phoenix-ios/views/main/MainView_Big.swift index f4f91d2f7..e868b9c9d 100644 --- a/phoenix-ios/phoenix-ios/views/main/MainView_Big.swift +++ b/phoenix-ios/phoenix-ios/views/main/MainView_Big.swift @@ -20,11 +20,6 @@ fileprivate enum TrailingSidebarContent { case currencyConverter } -fileprivate enum NavLinkTag: String { - case ReceiveView - case SendView -} - struct MainView_Big: View { @Namespace var splitView_leadingSidebar @@ -42,11 +37,6 @@ struct MainView_Big: View { @State var settingsViewRendered: Bool = false @State var transactionsViewRendered: Bool = false - @State private var navLinkTag: NavLinkTag? = nil - - let externalLightningUrlPublisher = AppDelegate.get().externalLightningUrlPublisher - @State var externalLightningRequest: AppScanController? = nil - let sidebarDividerWidth: CGFloat = 1 let headerButtonPaddingTop: CGFloat = 15 @@ -58,16 +48,6 @@ struct MainView_Big: View { ) @State var headerButtonHeight: CGFloat? = nil - enum FooterButtonWidth: Preference {} - let footerButtonWidthReader = GeometryPreferenceReader( - key: AppendValue.self, - value: { [$0.size.width] } - ) - @State var footerButtonWidth: CGFloat? = nil - - @ScaledMetric var sendImageSize: CGFloat = 22 - @ScaledMetric var receiveImageSize: CGFloat = 22 - @EnvironmentObject var deepLinkManager: DeepLinkManager // -------------------------------------------------- @@ -86,15 +66,9 @@ struct MainView_Big: View { .onChange(of: deviceInfo.windowSize) { newSize in windowSizeDidChange(newSize) } - .onChange(of: navLinkTag) { - navLinkTagChanged($0) - } .onChange(of: deepLinkManager.deepLink) { deepLinkChanged($0) } - .onReceive(externalLightningUrlPublisher) { - didReceiveExternalLightningUrl($0) - } } @ViewBuilder @@ -339,134 +313,8 @@ struct MainView_Big: View { @ViewBuilder func primary() -> some View { - NavigationWrapper { - ZStack { - if #unavailable(iOS 16.0) { - // iOS 14 & 15 have bugs when using NavigationLink. - // The suggested workarounds include using only a single NavigationLink. - // - NavigationLink( - destination: primary_navLinkView(), - isActive: navLinkTagBinding(nil) - ) { - EmptyView() - } - .isDetailLink(false) - - } // else: uses.navigationStackDestination() - - Color.primaryBackground - .ignoresSafeArea() - - if BusinessManager.showTestnetBackground { - Image("testnet_bg") - .resizable(resizingMode: .tile) - .ignoresSafeArea() - } - - primary_body() - - } // - .navigationTitle("") - .navigationBarTitleDisplayMode(.inline) - .navigationBarHidden(true) - .navigationStackDestination(isPresented: navLinkTagBinding(.SendView)) { // For iOS 16+ - primary_navLinkView() - } - .navigationStackDestination(isPresented: navLinkTagBinding(.ReceiveView)) { // For iOS 16+ - primary_navLinkView() - } - - } // - .padding(.top, navigationViewPaddingTop) - } - - @ViewBuilder - func primary_body() -> some View { - - VStack(alignment: HorizontalAlignment.center, spacing: 0) { - HomeView() - .padding(.bottom, 15) - primary_footer() - } - .padding(.bottom, 60) - } - - @ViewBuilder - func primary_footer() -> some View { - - HStack(alignment: VerticalAlignment.center, spacing: 20) { - - Button { - withAnimation { - navLinkTag = .ReceiveView - } - } label: { - Label { - Text("Receive") - .font(.title2.weight(.medium)) - .foregroundColor(.white) - } icon: { - Image("ic_receive_resized") - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundColor(Color.white) - .frame(width: receiveImageSize, height: receiveImageSize, alignment: .center) - } - .padding(.leading, 16) - .padding(.trailing, 18) - .padding(.top, 9) - .padding(.bottom, 9) - } // - .buttonStyle(ScaleButtonStyle( - cornerRadius: 100, - backgroundFill: Color.appAccent - )) - .frame(minWidth: footerButtonWidth, alignment: Alignment.center) - .read(footerButtonWidthReader) - - Button { - withAnimation { - navLinkTag = .SendView - } - } label: { - Label { - Text("Send") - .font(.title2.weight(.medium)) - .foregroundColor(.white) - } icon: { - Image("ic_scan_resized") - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundColor(.white) - .frame(width: sendImageSize, height: sendImageSize, alignment: .center) - } - .padding(.leading, 26) - .padding(.trailing, 28) - .padding(.top, 9) - .padding(.bottom, 9) - } // - .buttonStyle(ScaleButtonStyle( - cornerRadius: 100, - backgroundFill: Color.appAccent - )) - .frame(minWidth: footerButtonWidth, alignment: Alignment.center) - .read(footerButtonWidthReader) - - } // - } - - @ViewBuilder - func primary_navLinkView() -> some View { - - switch navLinkTag { - case .ReceiveView: - ReceiveView() - case .SendView: - SendView(controller: externalLightningRequest) - default: - EmptyView() - } + MainView_BigPrimary() + .padding(.top, navigationViewPaddingTop) } @ViewBuilder @@ -507,21 +355,6 @@ struct MainView_Big: View { // MARK: View Helpers // -------------------------------------------------- - private func navLinkTagBinding(_ tag: NavLinkTag?) -> Binding { - - if let tag { // specific tag - return Binding( - get: { navLinkTag == tag }, - set: { if $0 { navLinkTag = tag } else if (navLinkTag == tag) { navLinkTag = nil } } - ) - } else { // any tag - return Binding( - get: { navLinkTag != nil }, - set: { if !$0 { navLinkTag = nil }} - ) - } - } - var isShowingLeadingSidebar: Bool { return leadingSidebarContent != nil } @@ -743,6 +576,50 @@ struct MainView_Big: View { } } + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + private func windowSizeDidChange(_ windowSize: CGSize) { + log.trace("windowSizeDidChange() => w(\(windowSize.width)) height(\(windowSize.height))") + + if wasIPadLandscapeFullscreen && !deviceInfo.isIPadLandscapeFullscreen { + // Transitioning away from landscapeFullscreen. + // The common thing to do here is to hide the leading sidebar. + if leadingSidebarContent != nil { + leadingSidebarContent = nil + } + + } else if isShowingLeadingSidebar && isShowingTrailingSidebar { + let totalWidth = leadingSidebarWidth + trailingSidebarWidth + if (totalWidth + 10) > windowSize.width { + // Pop leading sidebar. + // This is the less destructive action, + // since content in the leading sidebar is simply hidden (not removed from view hierarchy), + // and can be restored by re-opening the leading sidebar. + leadingSidebarContent = nil + } + } + + wasIPadLandscapeFullscreen = deviceInfo.isIPadLandscapeFullscreen + } + + private func deepLinkChanged(_ value: DeepLink?) { + log.trace("deepLinkChanged() => \(value?.rawValue ?? "nil")") + + if let value = value { + switch value { + case .paymentHistory : showTransactions() + case .backup : showSettings() + case .drainWallet : showSettings() + case .electrum : showSettings() + case .backgroundPayments : showSettings() + case .liquiditySettings : showSettings() + case .forceCloseChannels : showSettings() + } + } + } + // -------------------------------------------------- // MARK: Actions // -------------------------------------------------- @@ -828,72 +705,4 @@ struct MainView_Big: View { trailingSidebarContent = nil } } - - // -------------------------------------------------- - // MARK: Notifications - // -------------------------------------------------- - - private func windowSizeDidChange(_ windowSize: CGSize) { - log.trace("windowSizeDidChange() => w(\(windowSize.width)) height(\(windowSize.height))") - - if wasIPadLandscapeFullscreen && !deviceInfo.isIPadLandscapeFullscreen { - // Transitioning away from landscapeFullscreen. - // The common thing to do here is to hide the leading sidebar. - if leadingSidebarContent != nil { - leadingSidebarContent = nil - } - - } else if isShowingLeadingSidebar && isShowingTrailingSidebar { - let totalWidth = leadingSidebarWidth + trailingSidebarWidth - if (totalWidth + 10) > windowSize.width { - // Pop leading sidebar. - // This is the less destructive action, - // since content in the leading sidebar is simply hidden (not removed from view hierarchy), - // and can be restored by re-opening the leading sidebar. - leadingSidebarContent = nil - } - } - - wasIPadLandscapeFullscreen = deviceInfo.isIPadLandscapeFullscreen - } - - private func deepLinkChanged(_ value: DeepLink?) { - log.trace("deepLinkChanged() => \(value?.rawValue ?? "nil")") - - if let value = value { - switch value { - case .paymentHistory : showTransactions() - case .backup : showSettings() - case .drainWallet : showSettings() - case .electrum : showSettings() - case .backgroundPayments : showSettings() - case .liquiditySettings : showSettings() - } - } - } - - private func navLinkTagChanged(_ tag: NavLinkTag?) { - log.trace("navLinkTagChanged() => \(tag?.rawValue ?? "nil")") - - if tag == nil { - // If we pushed the SendView, triggered by an external lightning url, - // then we can nil out the associated controller now (since we handed off to SendView). - self.externalLightningRequest = nil - } - } - - private func didReceiveExternalLightningUrl(_ urlStr: String) -> Void { - log.trace("didReceiveExternalLightningUrl()") - - if navLinkTag == .SendView { - log.debug("Ignoring: handled by SendView") - return - } - - MainViewHelper.shared.processExternalLightningUrl(urlStr) { scanController in - - self.externalLightningRequest = scanController - self.navLinkTag = .SendView - } - } } diff --git a/phoenix-ios/phoenix-ios/views/main/MainView_BigPrimary.swift b/phoenix-ios/phoenix-ios/views/main/MainView_BigPrimary.swift new file mode 100644 index 000000000..ae112b5fd --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/main/MainView_BigPrimary.swift @@ -0,0 +1,267 @@ +import SwiftUI +import PhoenixShared +import os.log + +#if DEBUG && true +fileprivate var log = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: "MainView_BigPrimary" +) +#else +fileprivate var log = Logger(OSLog.disabled) +#endif + +fileprivate enum NavLinkTag: String { + case ReceiveView + case SendView +} + + +struct MainView_BigPrimary: View { + + @State private var navLinkTag: NavLinkTag? = nil + + @State var didAppear = false + + let externalLightningUrlPublisher = AppDelegate.get().externalLightningUrlPublisher + @State var externalLightningRequest: AppScanController? = nil + + enum FooterButtonWidth: Preference {} + let footerButtonWidthReader = GeometryPreferenceReader( + key: AppendValue.self, + value: { [$0.size.width] } + ) + @State var footerButtonWidth: CGFloat? = nil + + @ScaledMetric var sendImageSize: CGFloat = 22 + @ScaledMetric var receiveImageSize: CGFloat = 22 + + @EnvironmentObject var popoverState: PopoverState + @EnvironmentObject var deepLinkManager: DeepLinkManager + + // -------------------------------------------------- + // MARK: View Builders + // -------------------------------------------------- + + @ViewBuilder + var body: some View { + + NavigationWrapper { + layers() + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .navigationBarHidden(true) + } + } + + @ViewBuilder + func layers() -> some View { + + ZStack { + if #unavailable(iOS 16.0) { + // iOS 14 & 15 have bugs when using NavigationLink. + // The suggested workarounds include using only a single NavigationLink. + // + NavigationLink( + destination: primary_navLinkView(), + isActive: navLinkTagBinding() + ) { + EmptyView() + } + .isDetailLink(false) + + } // else: uses.navigationStackDestination() + + Color.primaryBackground + .ignoresSafeArea() + + if BusinessManager.showTestnetBackground { + Image("testnet_bg") + .resizable(resizingMode: .tile) + .ignoresSafeArea() + } + + primary_body() + + } // + .navigationStackDestination(isPresented: navLinkTagBinding()) { // For iOS 16+ + primary_navLinkView() + } + .onAppear { + onAppear() + } + .onChange(of: navLinkTag) { + navLinkTagChanged($0) + } + .onReceive(externalLightningUrlPublisher) { + didReceiveExternalLightningUrl($0) + } + } + + @ViewBuilder + func primary_body() -> some View { + + VStack(alignment: HorizontalAlignment.center, spacing: 0) { + HomeView(showSwapInWallet: showSwapInWallet) + .padding(.bottom, 15) + primary_footer() + } + .padding(.bottom, 60) + } + + @ViewBuilder + func primary_footer() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 20) { + + Button { + withAnimation { + navLinkTag = .ReceiveView + } + } label: { + Label { + Text("Receive") + .font(.title2.weight(.medium)) + .foregroundColor(.white) + } icon: { + Image("ic_receive_resized") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(Color.white) + .frame(width: receiveImageSize, height: receiveImageSize, alignment: .center) + } + .padding(.leading, 16) + .padding(.trailing, 18) + .padding(.top, 9) + .padding(.bottom, 9) + } // + .buttonStyle(ScaleButtonStyle( + cornerRadius: 100, + backgroundFill: Color.appAccent + )) + .frame(minWidth: footerButtonWidth, alignment: Alignment.center) + .read(footerButtonWidthReader) + + Button { + withAnimation { + navLinkTag = .SendView + } + } label: { + Label { + Text("Send") + .font(.title2.weight(.medium)) + .foregroundColor(.white) + } icon: { + Image("ic_scan_resized") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.white) + .frame(width: sendImageSize, height: sendImageSize, alignment: .center) + } + .padding(.leading, 26) + .padding(.trailing, 28) + .padding(.top, 9) + .padding(.bottom, 9) + } // + .buttonStyle(ScaleButtonStyle( + cornerRadius: 100, + backgroundFill: Color.appAccent + )) + .frame(minWidth: footerButtonWidth, alignment: Alignment.center) + .read(footerButtonWidthReader) + + } // + } + + @ViewBuilder + func primary_navLinkView() -> some View { + + if let tag = navLinkTag { + switch tag { + case .ReceiveView : ReceiveView() + case .SendView : SendView(controller: externalLightningRequest) + } + } else { + EmptyView() + } + } + + // -------------------------------------------------- + // MARK: View Helpers + // -------------------------------------------------- + + private func navLinkTagBinding() -> Binding { + + return Binding( + get: { navLinkTag != nil }, + set: { if !$0 { navLinkTag = nil }} + ) + } + + // -------------------------------------------------- + // MARK: View Lifecycle + // -------------------------------------------------- + + func onAppear() { + log.trace("onAppear()") + + // Careful: this function may be called when returning from the Receive/Send view + if !didAppear { + didAppear = true + + // Reserved for future use + } + } + + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + private func navLinkTagChanged(_ tag: NavLinkTag?) { + log.trace("navLinkTagChanged() => \(tag?.rawValue ?? "nil")") + + if tag == nil { + // If we pushed the SendView, triggered by an external lightning url, + // then we can nil out the associated controller now (since we handed off to SendView). + self.externalLightningRequest = nil + } + } + + private func didReceiveExternalLightningUrl(_ urlStr: String) -> Void { + log.trace("didReceiveExternalLightningUrl()") + + if navLinkTag == .SendView { + log.debug("Ignoring: handled by SendView") + return + } + + MainViewHelper.shared.processExternalLightningUrl(urlStr) { scanController in + + self.externalLightningRequest = scanController + self.navLinkTag = .SendView + } + } + + // -------------------------------------------------- + // MARK: Actions + // -------------------------------------------------- + + func showSwapInWallet() { + log.trace("showSwapInWallet()") + + popoverState.display(dismissable: true) { + SwapInWalletDetails(location: .popover, popTo: popTo) + .frame(maxHeight: 600) + } + } + + func popTo(_ destination: PopToDestination) { + log.trace("popTo(\(destination))") + + popoverState.close { + if let deepLink = destination.followedBy { + deepLinkManager.broadcast(deepLink) + } + } + } +} diff --git a/phoenix-ios/phoenix-ios/views/main/MainView_Small.swift b/phoenix-ios/phoenix-ios/views/main/MainView_Small.swift index f205709e2..71265dca7 100644 --- a/phoenix-ios/phoenix-ios/views/main/MainView_Small.swift +++ b/phoenix-ios/phoenix-ios/views/main/MainView_Small.swift @@ -17,6 +17,7 @@ fileprivate enum NavLinkTag: String { case ReceiveView case SendView case CurrencyConverter + case SwapInWalletDetails } struct MainView_Small: View { @@ -28,15 +29,14 @@ struct MainView_Small: View { let externalLightningUrlPublisher = AppDelegate.get().externalLightningUrlPublisher @State var externalLightningRequest: AppScanController? = nil + @State var popToDestination: PopToDestination? = nil + @State private var swiftUiBugWorkaround: NavLinkTag? = nil @State private var swiftUiBugWorkaroundIdx = 0 @ScaledMetric var sendImageSize: CGFloat = 17 @ScaledMetric var receiveImageSize: CGFloat = 18 - @EnvironmentObject var deviceInfo: DeviceInfo - @EnvironmentObject var deepLinkManager: DeepLinkManager - let headerButtonHeightReader = GeometryPreferenceReader( key: AppendValue.self, value: { [$0.size.height] } @@ -57,15 +57,13 @@ struct MainView_Small: View { ) @State var footerButtonHeight: CGFloat? = nil - @State var footerTruncationDetection_standard: [ContentSizeCategory: Bool] = [:] - @State var footerTruncationDetection_condensed: [ContentSizeCategory: Bool] = [:] + @State var footerTruncationDetection_standard: [DynamicTypeSize: Bool] = [:] + @State var footerTruncationDetection_condensed: [DynamicTypeSize: Bool] = [:] - @Environment(\.sizeCategory) var contentSizeCategory: ContentSizeCategory + @Environment(\.dynamicTypeSize) var dynamicTypeSize: DynamicTypeSize - // When we drop iOS 14 support, switch to this: -// @State var footerTruncationDetection_standard: [DynamicTypeSize: Bool] = [:] -// @State var footerTruncationDetection_condensed: [DynamicTypeSize: Bool] = [:] -// @Environment(\.dynamicTypeSize) var dynamicTypeSize: DynamicTypeSize + @EnvironmentObject var deviceInfo: DeviceInfo + @EnvironmentObject var deepLinkManager: DeepLinkManager // -------------------------------------------------- // MARK: View Builders @@ -102,7 +100,7 @@ struct MainView_Small: View { // The suggested workarounds include using only a single NavigationLink. NavigationLink( destination: navLinkView(), - isActive: navLinkTagBinding(nil) + isActive: navLinkTagBinding() ) { EmptyView() } @@ -124,19 +122,7 @@ struct MainView_Small: View { } // .frame(maxWidth: .infinity, maxHeight: .infinity) - .navigationStackDestination(isPresented: navLinkTagBinding(.ConfigurationView)) { // For iOS 16+ - navLinkView() - } - .navigationStackDestination(isPresented: navLinkTagBinding(.TransactionsView)) { // For iOS 16+ - navLinkView() - } - .navigationStackDestination(isPresented: navLinkTagBinding(.ReceiveView)) { // For iOS 16+ - navLinkView() - } - .navigationStackDestination(isPresented: navLinkTagBinding(.SendView)) { // For iOS 16+ - navLinkView() - } - .navigationStackDestination(isPresented: navLinkTagBinding(.CurrencyConverter)) { // For iOS 16+ + .navigationStackDestination(isPresented: navLinkTagBinding()) { // For iOS 16+ navLinkView() } .onChange(of: deepLinkManager.deepLink) { @@ -155,7 +141,7 @@ struct MainView_Small: View { VStack(alignment: HorizontalAlignment.center, spacing: 0) { header() - HomeView() + HomeView(showSwapInWallet: showSwapInWallet) footer() } } @@ -250,17 +236,17 @@ struct MainView_Small: View { @ViewBuilder func footer() -> some View { - let csc = contentSizeCategory - let footerTruncationDetected_condensed = footerTruncationDetection_condensed[csc] ?? false - let footerTruncationDetected_standard = footerTruncationDetection_standard[csc] ?? false + let dts = dynamicTypeSize + let footerTruncationDetected_condensed = footerTruncationDetection_condensed[dts] ?? false + let footerTruncationDetected_standard = footerTruncationDetection_standard[dts] ?? false Group { if footerTruncationDetected_condensed { footer_accessibility() } else if footerTruncationDetected_standard { - footer_condensed(csc) + footer_condensed(dts) } else { - footer_standard(csc) + footer_standard(dts) } } .padding(.top, 20) @@ -275,7 +261,7 @@ struct MainView_Small: View { } @ViewBuilder - func footer_standard(_ csc: ContentSizeCategory) -> some View { + func footer_standard(_ dts: DynamicTypeSize) -> some View { // We're trying to center the divider: // @@ -299,8 +285,8 @@ struct MainView_Small: View { .lineLimit(1) .foregroundColor(.primaryForeground) } wasTruncated: { - log.debug("footerTruncationDetected_standard(receive): \(csc)") - self.footerTruncationDetection_standard[csc] = true + log.debug("footerTruncationDetected_standard(receive): \(dts)") + self.footerTruncationDetection_standard[dts] = true } } icon: { Image("ic_receive_resized") @@ -330,8 +316,8 @@ struct MainView_Small: View { .lineLimit(1) .foregroundColor(.primaryForeground) } wasTruncated: { - log.debug("footerTruncationDetected_standard(send): \(csc)") - self.footerTruncationDetection_standard[csc] = true + log.debug("footerTruncationDetected_standard(send): \(dts)") + self.footerTruncationDetection_standard[dts] = true } } icon: { Image("ic_scan_resized") @@ -352,7 +338,7 @@ struct MainView_Small: View { } @ViewBuilder - func footer_condensed(_ csc: ContentSizeCategory) -> some View { + func footer_condensed(_ dts: DynamicTypeSize) -> some View { // There's a large font being used, and possibly a small screen too. // Thus horizontal space is tight. @@ -376,8 +362,8 @@ struct MainView_Small: View { .lineLimit(1) .foregroundColor(.primaryForeground) } wasTruncated: { - log.debug("footerTruncationDetected_condensed(receive): \(csc)") - self.footerTruncationDetection_condensed[csc] = true + log.debug("footerTruncationDetected_condensed(receive): \(dts)") + self.footerTruncationDetection_condensed[dts] = true } } icon: { Image("ic_receive_resized") @@ -405,8 +391,8 @@ struct MainView_Small: View { .lineLimit(1) .foregroundColor(.primaryForeground) } wasTruncated: { - log.debug("footerTruncationDetected_condensed(send): \(csc)") - self.footerTruncationDetection_condensed[csc] = true + log.debug("footerTruncationDetected_condensed(send): \(dts)") + self.footerTruncationDetection_condensed[dts] = true } } icon: { Image("ic_scan_resized") @@ -491,11 +477,12 @@ struct MainView_Small: View { private func navLinkView(_ tag: NavLinkTag) -> some View { switch tag { - case .ConfigurationView : ConfigurationView() - case .TransactionsView : TransactionsView() - case .ReceiveView : ReceiveView() - case .SendView : SendView(controller: externalLightningRequest) - case .CurrencyConverter : CurrencyConverterView() + case .ConfigurationView : ConfigurationView() + case .TransactionsView : TransactionsView() + case .ReceiveView : ReceiveView() + case .SendView : SendView(controller: externalLightningRequest) + case .CurrencyConverter : CurrencyConverterView() + case .SwapInWalletDetails : SwapInWalletDetails(location: .embedded, popTo: popTo) } } @@ -503,19 +490,12 @@ struct MainView_Small: View { // MARK: View Helpers // -------------------------------------------------- - private func navLinkTagBinding(_ tag: NavLinkTag?) -> Binding { + private func navLinkTagBinding() -> Binding { - if let tag { // specific tag - return Binding( - get: { navLinkTag == tag }, - set: { if $0 { navLinkTag = tag } else if (navLinkTag == tag) { navLinkTag = nil } } - ) - } else { // any tag - return Binding( - get: { navLinkTag != nil }, - set: { if !$0 { navLinkTag = nil }} - ) - } + return Binding( + get: { navLinkTag != nil }, + set: { if !$0 { navLinkTag = nil }} + ) } // -------------------------------------------------- @@ -534,9 +514,10 @@ struct MainView_Small: View { case .paymentHistory : newNavLinkTag = .TransactionsView ; delay *= 1 case .backup : newNavLinkTag = .ConfigurationView ; delay *= 2 case .drainWallet : newNavLinkTag = .ConfigurationView ; delay *= 2 - case .electrum : newNavLinkTag = .ConfigurationView ; delay *= 3 + case .electrum : newNavLinkTag = .ConfigurationView ; delay *= 2 case .backgroundPayments : newNavLinkTag = .ConfigurationView ; delay *= 3 case .liquiditySettings : newNavLinkTag = .ConfigurationView ; delay *= 3 + case .forceCloseChannels : newNavLinkTag = .ConfigurationView ; delay *= 2 } if let newNavLinkTag = newNavLinkTag { @@ -567,6 +548,22 @@ struct MainView_Small: View { // If we pushed the SendView, triggered by an external lightning url, // then we can nil out the associated controller now (since we handed off to SendView). self.externalLightningRequest = nil + + // If there's a pending popToDestination, it's now safe to continue the flow. + // + // Note that performing this operation in `onAppear` doesn't work properly: + // - it appears to work fine on the simulator, but isn't reliable on the actual device + // - it seems that, IF using a `navLinkTag`, then we need to wait for the tag to be + // unset before it can be set properly again. + // + if let destination = popToDestination { + log.debug("popToDestination: \(destination)") + + popToDestination = nil + if let deepLink = destination.followedBy { + deepLinkManager.broadcast(deepLink) + } + } } } @@ -585,6 +582,22 @@ struct MainView_Small: View { } } + // -------------------------------------------------- + // MARK: Actions + // -------------------------------------------------- + + func showSwapInWallet() { + log.trace("showSwapInWallet()") + + navLinkTag = .SwapInWalletDetails + } + + func popTo(_ destination: PopToDestination) { + log.trace("popTo(\(destination))") + + popToDestination = destination + } + // -------------------------------------------------- // MARK: Utilities // -------------------------------------------------- @@ -602,23 +615,3 @@ struct MainView_Small: View { } } } - -extension ContentSizeCategory: CustomStringConvertible { - public var description: String { - switch self { - case .extraSmall : return "XS" - case .small : return "S" - case .medium : return "M" - case .large : return "L" - case .extraLarge : return "XL" - case .extraExtraLarge : return "XXL" - case .extraExtraExtraLarge : return "XXXL" - case .accessibilityMedium : return "aM" - case .accessibilityLarge : return "aL" - case .accessibilityExtraLarge : return "aXL" - case .accessibilityExtraExtraLarge : return "aXXL" - case .accessibilityExtraExtraExtraLarge : return "aXXXL" - @unknown default : return "U" - } - } -} diff --git a/phoenix-ios/phoenix-ios/views/notifications/NotificationsView.swift b/phoenix-ios/phoenix-ios/views/notifications/NotificationsView.swift index 498a4b1d5..82d5446d9 100644 --- a/phoenix-ios/phoenix-ios/views/notifications/NotificationsView.swift +++ b/phoenix-ios/phoenix-ios/views/notifications/NotificationsView.swift @@ -22,9 +22,9 @@ struct NotificationsView : View { @State var bizNotifications_watchtower: [PhoenixShared.NotificationsManager.NotificationItem] = [] @Environment(\.openURL) var openURL - @Environment(\.popoverState) var popoverState: PopoverState @Environment(\.presentationMode) var presentationMode: Binding + @EnvironmentObject var popoverState: PopoverState @EnvironmentObject var deepLinkManager: DeepLinkManager // -------------------------------------------------- diff --git a/phoenix-ios/phoenix-ios/views/receive/CopyOptionsSheet.swift b/phoenix-ios/phoenix-ios/views/receive/CopyOptionsSheet.swift index 389b4e314..e1b6e2a52 100644 --- a/phoenix-ios/phoenix-ios/views/receive/CopyOptionsSheet.swift +++ b/phoenix-ios/phoenix-ios/views/receive/CopyOptionsSheet.swift @@ -11,7 +11,7 @@ struct CopyOptionsSheet: View { let copyText: () -> Void let copyImage: () -> Void - @Environment(\.smartModalState) var smartModalState: SmartModalState + @EnvironmentObject var smartModalState: SmartModalState @ViewBuilder var body: some View { diff --git a/phoenix-ios/phoenix-ios/views/receive/ModifyInvoiceSheet.swift b/phoenix-ios/phoenix-ios/views/receive/ModifyInvoiceSheet.swift index fb1be75c0..436f7ba1e 100644 --- a/phoenix-ios/phoenix-ios/views/receive/ModifyInvoiceSheet.swift +++ b/phoenix-ios/phoenix-ios/views/receive/ModifyInvoiceSheet.swift @@ -33,9 +33,8 @@ struct ModifyInvoiceSheet: View { @State var isInvalidAmount: Bool = false @State var isEmptyAmount: Bool = false - @EnvironmentObject private var currencyPrefs: CurrencyPrefs - - @Environment(\.smartModalState) var smartModalState: SmartModalState + @EnvironmentObject var currencyPrefs: CurrencyPrefs + @EnvironmentObject var smartModalState: SmartModalState // Workaround for SwiftUI bug enum TextHeight: Preference {} diff --git a/phoenix-ios/phoenix-ios/views/receive/ReceiveLightningView.swift b/phoenix-ios/phoenix-ios/views/receive/ReceiveLightningView.swift index bba1864cd..281569acc 100644 --- a/phoenix-ios/phoenix-ios/views/receive/ReceiveLightningView.swift +++ b/phoenix-ios/phoenix-ios/views/receive/ReceiveLightningView.swift @@ -11,6 +11,11 @@ fileprivate var log = Logger( fileprivate var log = Logger(OSLog.disabled) #endif +struct InboundFeeWarning { + let title: LocalizedStringKey + let message: LocalizedStringKey + let showButton: Bool +} struct ReceiveLightningView: View { @@ -34,28 +39,30 @@ struct ReceiveLightningView: View { } @State var activeSheet: ReceiveViewSheet? = nil - @State var swapIn_enabled = true @State var payToOpen_enabled = true - @State var channelsCount = 0 - + @State var channels: [LocalChannelInfo] = [] + @State var liquidityPolicy: LiquidityPolicy = Prefs.shared.liquidityPolicy @State var notificationPermissions = NotificationsManager.shared.permissions.value @State var modificationAmount: CurrencyAmount? = nil @State var currencyConverterOpen = false + @State var mempoolRecommendedResponse: MempoolRecommendedResponse? = nil + @Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass? @Environment(\.verticalSizeClass) var verticalSizeClass: UserInterfaceSizeClass? @Environment(\.presentationMode) var presentationMode: Binding @Environment(\.colorScheme) var colorScheme: ColorScheme - @Environment(\.popoverState) var popoverState: PopoverState - @Environment(\.smartModalState) var smartModalState: SmartModalState @EnvironmentObject var currencyPrefs: CurrencyPrefs @EnvironmentObject var deepLinkManager: DeepLinkManager + @EnvironmentObject var popoverState: PopoverState + @EnvironmentObject var smartModalState: SmartModalState let lastIncomingPaymentPublisher = Biz.business.paymentsManager.lastIncomingPaymentPublisher() let chainContextPublisher = Biz.business.appConfigurationManager.chainContextPublisher() + let channelsPublisher = Biz.business.peerManager.channelsPublisher() // For the cicular buttons: [copy, share, edit] enum MaxButtonWidth: Preference {} @@ -72,6 +79,14 @@ struct ReceiveLightningView: View { @ViewBuilder var body: some View { + content() + .navigationTitle(NSLocalizedString("Receive", comment: "Navigation bar title")) + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + func content() -> some View { + ZStack { if #unavailable(iOS 16.0) { NavigationLink( @@ -84,25 +99,14 @@ struct ReceiveLightningView: View { } // else: uses.navigationStackDestination() - Color.primaryBackground - .edgesIgnoringSafeArea(.all) - - if BusinessManager.showTestnetBackground { - Image("testnet_bg") - .resizable(resizingMode: .tile) - .edgesIgnoringSafeArea([.horizontal, .bottom]) // not underneath status bar - .accessibilityHidden(true) - } - - content() + mainWrapper() } .onAppear { onAppear() } - .navigationStackDestination( // For iOS 16+ - isPresented: $currencyConverterOpen, - destination: currencyConverterView - ) + .navigationStackDestination(isPresented: $currencyConverterOpen) { // For iOS 16+ + currencyConverterView() + } .onChange(of: mvi.model) { newModel in onModelChange(model: newModel) } @@ -112,6 +116,12 @@ struct ReceiveLightningView: View { .onReceive(chainContextPublisher) { chainContextChanged($0) } + .onReceive(channelsPublisher) { + channelsChanged($0) + } + .onReceive(Prefs.shared.liquidityPolicyPublisher) { + liquidityPolicyChanged($0) + } .onReceive(NotificationsManager.shared.permissions) { notificationPermissionsChanged($0) } @@ -132,40 +142,36 @@ struct ReceiveLightningView: View { } // } - .navigationTitle(NSLocalizedString("Receive", comment: "Navigation bar title")) - .navigationBarTitleDisplayMode(.inline) + .task { + await fetchMempoolRecommendedFees() + } } @ViewBuilder - func content() -> some View { + func mainWrapper() -> some View { if isFullScreenQrcode { fullScreenQrcode() } else { - mainWrapper() - } - } - - @ViewBuilder - func mainWrapper() -> some View { - - // SwiftUI BUG workaround: - // - // When the keyboard appears, the main view shouldn't move. At all. - // It should perform ZERO keyboard avoidance. - // Which means we need to use: `.ignoresSafeArea(.keyboard)` - // - // But, of course, this doesn't work properly because of a SwiftUI bug. - // So the current recommended workaround is to wrap everything in a GeometryReader. - // - GeometryReader { geometry in - ScrollView(.vertical) { - main() - .frame(width: geometry.size.width) - .frame(minHeight: geometry.size.height) + + // SwiftUI BUG workaround: + // + // When the keyboard appears, the main view shouldn't move. At all. + // It should perform ZERO keyboard avoidance. + // Which means we need to use: `.ignoresSafeArea(.keyboard)` + // + // But, of course, this doesn't work properly because of a SwiftUI bug. + // So the current recommended workaround is to wrap everything in a GeometryReader. + // + GeometryReader { geometry in + ScrollView(.vertical) { + main() + .frame(width: geometry.size.width) + .frame(minHeight: geometry.size.height) + } } + .ignoresSafeArea(.keyboard) } - .ignoresSafeArea(.keyboard) } @ViewBuilder @@ -204,14 +210,14 @@ struct ReceiveLightningView: View { VStack(alignment: .center) { - invoiceAmount() - .font(.caption2) + invoiceAmountView() + .font(.footnote) .foregroundColor(.secondary) .padding(.bottom, 2) Text(invoiceDescription()) .lineLimit(1) - .font(.caption2) + .font(.footnote) .foregroundColor(.secondary) .padding(.bottom, 2) } @@ -225,7 +231,8 @@ struct ReceiveLightningView: View { } .assignMaxPreference(for: maxButtonWidthReader.key, to: $maxButtonWidth) - warningButton() + backgroundPaymentsDisabledWarning() + inboundFeeWarnings() Spacer() @@ -264,17 +271,18 @@ struct ReceiveLightningView: View { Spacer() - invoiceAmount() - .font(.caption2) + invoiceAmountView() + .font(.footnote) .foregroundColor(.secondary) .padding(.bottom, 6) Text(invoiceDescription()) .lineLimit(1) - .font(.caption2) + .font(.footnote) .foregroundColor(.secondary) - warningButton(paddingTop: 8) + backgroundPaymentsDisabledWarning(paddingTop: 8) + inboundFeeWarnings(paddingTop: 8) Spacer() } @@ -390,7 +398,7 @@ struct ReceiveLightningView: View { } @ViewBuilder - func invoiceAmount() -> some View { + func invoiceAmountView() -> some View { if let m = mvi.model as? Receive.Model_Generated { if let msat = m.amount?.msat { @@ -524,30 +532,6 @@ struct ReceiveLightningView: View { .disabled(!(mvi.model is Receive.Model_Generated)) } - @ViewBuilder - func warningButton(paddingTop: CGFloat? = nil) -> some View { - - if notificationPermissions == .disabled { - - // The user has disabled "background payments" - - Button { - navigationToBackgroundPayments() - } label: { - Label { - Text("Background payments disabled") - } icon: { - Image(systemName: "exclamationmark.triangle") - .renderingMode(.template) - } - .foregroundColor(.appNegative) - } - .padding(.top, paddingTop) - .accessibilityLabel("Warning: background payments disabled") - .accessibilityHint("Tap for more info") - } - } - @ViewBuilder func payToOpenDisabledWarning() -> some View { @@ -577,6 +561,62 @@ struct ReceiveLightningView: View { .padding([.leading, .trailing], 10) } + @ViewBuilder + func backgroundPaymentsDisabledWarning(paddingTop: CGFloat? = nil) -> some View { + + if notificationPermissions == .disabled { + + // The user has disabled "background payments" + + Button { + navigationToBackgroundPayments() + } label: { + Label { + Text("Background payments disabled") + } icon: { + Image(systemName: "exclamationmark.triangle") + .renderingMode(.template) + } + .foregroundColor(.appNegative) + } + .padding(.top, paddingTop) + .accessibilityLabel("Warning: background payments disabled") + .accessibilityHint("Tap for more info") + } + } + + @ViewBuilder + func inboundFeeWarnings(paddingTop: CGFloat? = nil) -> some View { + + + if let warning = inboundFeeWarning() { + + VStack(alignment: HorizontalAlignment.center, spacing: 10) { + Label { + Text(warning.title) + } icon: { + Image(systemName: "info.circle").foregroundColor(.appAccent) + } + .font(.headline) + + Text(warning.message) + .multilineTextAlignment(.center) + .font(.callout) + + if warning.showButton { + Button { + navigateToLiquiditySettings() + } label: { + Text("Check fee settings") + .font(.callout) + } + } + } + .padding(.horizontal) + .padding(.top, paddingTop) + } + } + @ViewBuilder func currencyConverterView() -> some View { @@ -591,6 +631,15 @@ struct ReceiveLightningView: View { // MARK: View Helpers // -------------------------------------------------- + func invoiceAmountMsat() -> Int64? { + + if let model = mvi.model as? Receive.Model_Generated { + return model.amount?.msat + } else { + return nil + } + } + func invoiceDescription() -> String { if let m = mvi.model as? Receive.Model_Generated { @@ -606,6 +655,75 @@ struct ReceiveLightningView: View { } } + func inboundFeeWarning() -> InboundFeeWarning? { + + let hasNoChannels = channels.filter { !$0.isTerminated }.isEmpty + if hasNoChannels && !liquidityPolicy.enabled { + + // strong warning => no channels + fee policy is disabled + return InboundFeeWarning( + title: "Payment will fail", + message: "Inbound liquidity is insufficient and you have disabled automated channel management.", + showButton: true + ) + } + + let availableForReceive = channels.map { $0.availableForReceive?.msat ?? Int64(0) }.sum() + var liquidityIsShort = false + if let invoiceAmount = invoiceAmountMsat() { + liquidityIsShort = invoiceAmount >= availableForReceive + } + + if hasNoChannels || liquidityIsShort { + + if !liquidityPolicy.enabled { + + // no fee policy => strong warning + return InboundFeeWarning( + title: "Payment may fail", + message: "Inbound liquidity is insufficient and you have disabled automated channel management.", + showButton: true + ) + } + + guard let swapFee = mempoolRecommendedResponse?.swapEstimationFee(hasNoChannels: hasNoChannels) else { + + // no fee information available => basic warning + return InboundFeeWarning( + title: "Fee expected", + message: "Inbound liquidity is insufficient.", + showButton: true // mostly because the message is hard to understand + ) + } + + if swapFee.sat > liquidityPolicy.effectiveMaxFeeSats { + + let limit = Utils.format(currencyPrefs, sat: liquidityPolicy.effectiveMaxFeeSats) + + // fee policy is short => light warning + return InboundFeeWarning( + title: "Payment may fail", + message: "The fee will probably be above your max limit (\(limit.string)).", + showButton: true + ) + + } else { + + let btcAmt = Utils.formatBitcoin(currencyPrefs, sat: swapFee) + let fiatAmt = Utils.formatFiat(currencyPrefs, sat: swapFee) + + // fee policy is within bounds => light warning + return InboundFeeWarning( + title: "Fee expected", + message: "A fee of \(btcAmt.string) (โ‰ˆ \(fiatAmt.string)) may be needed to receive this payment.", + showButton: false + ) + } + } + + return nil + } + // -------------------------------------------------- // MARK: View Transitions // -------------------------------------------------- @@ -660,10 +778,21 @@ struct ReceiveLightningView: View { func chainContextChanged(_ context: WalletContext.V0ChainContext) -> Void { log.trace("chainContextChanged()") - swapIn_enabled = context.swapIn.v1.status is WalletContext.V0ServiceStatusActive payToOpen_enabled = context.payToOpen.v1.status is WalletContext.V0ServiceStatusActive } + func channelsChanged(_ channels: [LocalChannelInfo]) { + log.trace("channelsChanged()") + + self.channels = channels + } + + func liquidityPolicyChanged(_ newValue: LiquidityPolicy) { + log.trace("liquidityPolicyChanged()") + + self.liquidityPolicy = newValue + } + func notificationPermissionsChanged(_ newValue: NotificationPermissions) { log.trace("notificationPermissionsChanged()") @@ -698,6 +827,20 @@ struct ReceiveLightningView: View { } } + // -------------------------------------------------- + // MARK: Tasks + // -------------------------------------------------- + + func fetchMempoolRecommendedFees() async { + + for try await response in MempoolMonitor.shared.stream() { + mempoolRecommendedResponse = response + if Task.isCancelled { + return + } + } + } + // -------------------------------------------------- // MARK: Actions // -------------------------------------------------- @@ -764,6 +907,12 @@ struct ReceiveLightningView: View { deepLinkManager.broadcast(DeepLink.backgroundPayments) } + func navigateToLiquiditySettings() { + log.trace("navigateToLiquiditySettings()") + + deepLinkManager.broadcast(DeepLink.liquiditySettings) + } + // -------------------------------------------------- // MARK: Utilities // -------------------------------------------------- diff --git a/phoenix-ios/phoenix-ios/views/receive/ReceiveView.swift b/phoenix-ios/phoenix-ios/views/receive/ReceiveView.swift index ece056401..c9f53fa5f 100644 --- a/phoenix-ios/phoenix-ios/views/receive/ReceiveView.swift +++ b/phoenix-ios/phoenix-ios/views/receive/ReceiveView.swift @@ -32,10 +32,17 @@ struct ReceiveView: MVIView { @State var swapIn_enabled = true + enum TabBarButtonHeight: Preference {} + let tabBarButtonHeightReader = GeometryPreferenceReader( + key: AppendValue.self, + value: { [$0.size.height] } + ) + @State var tabBarButtonHeight: CGFloat? = nil + @StateObject var toast = Toast() @Environment(\.colorScheme) var colorScheme - @Environment(\.popoverState) var popoverState: PopoverState + @EnvironmentObject var popoverState: PopoverState // -------------------------------------------------- // MARK: ViewBuilders @@ -45,17 +52,28 @@ struct ReceiveView: MVIView { var view: some View { ZStack { - customTabBar() + + Color.primaryBackground + .edgesIgnoringSafeArea(.all) + + if BusinessManager.showTestnetBackground { + Image("testnet_bg") + .resizable(resizingMode: .tile) + .edgesIgnoringSafeArea([.horizontal, .bottom]) // not underneath status bar + .accessibilityHidden(true) + } + + customTabView() + toast.view() } - .frame(maxWidth: .infinity, maxHeight: .infinity) .onChange(of: mvi.model) { newModel in onModelChange(model: newModel) } } @ViewBuilder - func customTabBar() -> some View { + func customTabView() -> some View { VStack(alignment: HorizontalAlignment.center, spacing: 0) { @@ -76,45 +94,63 @@ struct ReceiveView: MVIView { ) } - HStack(alignment: VerticalAlignment.center, spacing: 20) { - - Button { - didSelectTab(.lightning) - } label: { - HStack(alignment: VerticalAlignment.center, spacing: 4) { - Image(systemName: "bolt").font(.title2).imageScale(.large) - VStack(alignment: HorizontalAlignment.center, spacing: 4) { - Text("Lightning") - Text("(layer 2)").font(.footnote).opacity(0.7) - } + customTabBar() + .padding(.top, 15) + .background( + Color.mutedBackground + .cornerRadius(15, corners: [.topLeft, .topRight]) + .edgesIgnoringSafeArea(.bottom) // background color should extend to bottom of screen + ) + + } // + } + + @ViewBuilder + func customTabBar() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + + Spacer(minLength: 2) + + Button { + didSelectTab(.lightning) + } label: { + HStack(alignment: VerticalAlignment.center, spacing: 4) { + Image(systemName: "bolt").font(.title2).imageScale(.large) + VStack(alignment: HorizontalAlignment.center, spacing: 4) { + Text("Lightning") + Text("(layer 2)").font(.footnote.weight(.thin)).opacity(0.7) } } - .foregroundColor(selectedTab == .lightning ? Color.appAccent : Color.primary) - .frame(maxWidth: .infinity) - - Button { - didSelectTab(.blockchain) - } label: { - HStack(alignment: VerticalAlignment.center, spacing: 4) { - Image(systemName: "link").font(.title2).imageScale(.large) - VStack(alignment: HorizontalAlignment.center, spacing: 0) { - Text("Blockchain").padding(.bottom, 4) - Text("(layer 1)").font(.footnote).opacity(0.7) - } + } + .foregroundColor(selectedTab == .lightning ? Color.appAccent : Color.primary) + .read(tabBarButtonHeightReader) + + Spacer(minLength: 2) + if let tabBarButtonHeight { + Divider().frame(width: 1, height: tabBarButtonHeight).background(Color.borderColor) + Spacer(minLength: 2) + } + + Button { + didSelectTab(.blockchain) + } label: { + HStack(alignment: VerticalAlignment.center, spacing: 4) { + Image(systemName: "link").font(.title2).imageScale(.large) + VStack(alignment: HorizontalAlignment.center, spacing: 4) { + Text("Blockchain") + Text("(layer 1)").font(.footnote.weight(.thin)).opacity(0.7) } } - .foregroundColor(selectedTab == .blockchain ? Color.appAccent : Color.primary) - .frame(maxWidth: .infinity) - - } // - .padding(.top, 15) - .background( - Color.mutedBackground - .edgesIgnoringSafeArea(.bottom) // background color should extend to bottom of screen - ) + } + .foregroundColor(selectedTab == .blockchain ? Color.appAccent : Color.primary) + .read(tabBarButtonHeightReader) - } // - + Spacer(minLength: 2) + + } // + .clipped() // SwiftUI force-extends height of button to bottom of screen for some odd reason + .assignMaxPreference(for: tabBarButtonHeightReader.key, to: $tabBarButtonHeight) } // -------------------------------------------------- @@ -166,30 +202,3 @@ struct ReceiveView: MVIView { return (colorScheme == .dark) ? Color(UIColor.separator) : Color.appAccent } } - -// MARK: - - -class ReceiveView_Previews: PreviewProvider { - - static let request = "lntb17u1p0475jgpp5f69ep0f2202rqegjeddjxa3mdre6ke6kchzhzrn4rxzhyqakzqwqdpzxysy2umswfjhxum0yppk76twypgxzmnwvycqp2xqrrss9qy9qsqsp5nhhdgpz3549mll70udxldkg48s36cj05epp2cjjv3rrvs5hptdfqlq6h3tkkaplq4au9tx2k49tcp3gx7azehseq68jums4p0gt6aeu3gprw3r7ewzl42luhc3gyexaq37h3d73wejr70nvcw036cde4ptgpckmmkm" - - static var previews: some View { - - NavigationWrapper { - ReceiveView().mock(Receive.Model_Awaiting()) - } - .modifier(GlobalEnvironment()) - .previewDevice("iPhone 11") - - NavigationWrapper { - ReceiveView().mock(Receive.Model_Generated( - request: request, - paymentHash: "foobar", - amount: Lightning_kmpMilliSatoshi(msat: 170000), - desc: "1 Espresso Coin Panna" - )) - } - .modifier(GlobalEnvironment()) - .previewDevice("iPhone 11") - } -} diff --git a/phoenix-ios/phoenix-ios/views/receive/ShareOptionsSheet.swift b/phoenix-ios/phoenix-ios/views/receive/ShareOptionsSheet.swift index 33d731458..0e5e31ac4 100644 --- a/phoenix-ios/phoenix-ios/views/receive/ShareOptionsSheet.swift +++ b/phoenix-ios/phoenix-ios/views/receive/ShareOptionsSheet.swift @@ -11,7 +11,7 @@ struct ShareOptionsSheet: View { let shareText: () -> Void let shareImage: () -> Void - @Environment(\.smartModalState) var smartModalState: SmartModalState + @EnvironmentObject var smartModalState: SmartModalState @ViewBuilder var body: some View { diff --git a/phoenix-ios/phoenix-ios/views/receive/SwapInDisabledPopover.swift b/phoenix-ios/phoenix-ios/views/receive/SwapInDisabledPopover.swift index f33cd85ef..1a09ad5a7 100644 --- a/phoenix-ios/phoenix-ios/views/receive/SwapInDisabledPopover.swift +++ b/phoenix-ios/phoenix-ios/views/receive/SwapInDisabledPopover.swift @@ -2,7 +2,7 @@ import SwiftUI struct SwapInDisabledPopover: View { - @Environment(\.popoverState) var popoverState: PopoverState + @EnvironmentObject var popoverState: PopoverState @ViewBuilder var body: some View { diff --git a/phoenix-ios/phoenix-ios/views/receive/SwapInView.swift b/phoenix-ios/phoenix-ios/views/receive/SwapInView.swift index c47f5c1f4..92e696152 100644 --- a/phoenix-ios/phoenix-ios/views/receive/SwapInView.swift +++ b/phoenix-ios/phoenix-ios/views/receive/SwapInView.swift @@ -28,12 +28,13 @@ struct SwapInView: View { @State var activeSheet: ReceiveViewSheet? = nil - let swapInWalletBalancePublisher = Biz.business.balanceManager.swapInWalletBalancePublisher() - @State var swapInWalletBalance = Biz.business.balanceManager.swapInWalletBalanceValue() + let swapInWalletPublisher = Biz.business.balanceManager.swapInWalletPublisher() + @State var swapInWallet = Biz.business.balanceManager.swapInWalletValue() @Environment(\.colorScheme) var colorScheme: ColorScheme @Environment(\.presentationMode) var presentationMode: Binding - @Environment(\.smartModalState) var smartModalState: SmartModalState + + @EnvironmentObject var smartModalState: SmartModalState // For the cicular buttons: [copy, share] enum MaxButtonWidth: Preference {} @@ -50,24 +51,6 @@ struct SwapInView: View { @ViewBuilder var body: some View { - ZStack { - Color.primaryBackground - .edgesIgnoringSafeArea(.all) - - if BusinessManager.showTestnetBackground { - Image("testnet_bg") - .resizable(resizingMode: .tile) - .edgesIgnoringSafeArea([.horizontal, .bottom]) // not underneath status bar - .accessibilityHidden(true) - } - - contentWrapper() - } - } - - @ViewBuilder - func contentWrapper() -> some View { - GeometryReader { geometry in ScrollView(.vertical) { content() @@ -134,8 +117,8 @@ struct SwapInView: View { .onChange(of: mvi.model) { newModel in onModelChange(model: newModel) } - .onReceive(swapInWalletBalancePublisher) { - swapInWalletBalanceChanged($0) + .onReceive(swapInWalletPublisher) { + swapInWalletChanged($0) } } @@ -347,18 +330,18 @@ struct SwapInView: View { } } - func swapInWalletBalanceChanged(_ walletBalance: WalletBalance) { - log.trace("swapInWalletBalanceChanged()") + func swapInWalletChanged(_ newWallet: Lightning_kmpWalletState.WalletWithConfirmations) { + log.trace("swapInWalletChanged()") // If we detect a new incoming payment on the swap-in address, // then let's dismiss this sheet, and show the user the home screen. // // Because the home screen has the "+X sat incoming" message - let oldBalance = swapInWalletBalance.total.sat - let newBalance = walletBalance.total.sat + let oldBalance = swapInWallet.totalBalance.sat + let newBalance = newWallet.totalBalance.sat - swapInWalletBalance = walletBalance + swapInWallet = newWallet if newBalance > oldBalance { presentationMode.wrappedValue.dismiss() } diff --git a/phoenix-ios/phoenix-ios/views/send/CommentSheet.swift b/phoenix-ios/phoenix-ios/views/send/CommentSheet.swift index ee5570ef4..ab434c2e4 100644 --- a/phoenix-ios/phoenix-ios/views/send/CommentSheet.swift +++ b/phoenix-ios/phoenix-ios/views/send/CommentSheet.swift @@ -21,7 +21,7 @@ struct CommentSheet: View { let sendButtonAction: (() -> Void)? - @Environment(\.smartModalState) var smartModalState: SmartModalState + @EnvironmentObject var smartModalState: SmartModalState init(comment: Binding, maxCommentLength: Int, sendButtonAction: (() -> Void)? = nil) { self._comment = comment diff --git a/phoenix-ios/phoenix-ios/views/send/LnurlFlowErrorNotice.swift b/phoenix-ios/phoenix-ios/views/send/LnurlFlowErrorNotice.swift index e732cf69f..408791d33 100644 --- a/phoenix-ios/phoenix-ios/views/send/LnurlFlowErrorNotice.swift +++ b/phoenix-ios/phoenix-ios/views/send/LnurlFlowErrorNotice.swift @@ -21,7 +21,7 @@ struct LnurlFlowErrorNotice: View { let error: LnurlFlowError - @Environment(\.popoverState) var popoverState: PopoverState + @EnvironmentObject var popoverState: PopoverState @ViewBuilder var body: some View { @@ -119,10 +119,7 @@ struct LnurlFlowErrorNotice: View { } else if let err = payError as? Scan.LnurlPay_Error_ChainMismatch { - let lChain = err.expected.name - let rChain = err.actual.name - - Text("You are on bitcoin chain \(lChain), but the invoice is for \(rChain).") + Text("The invoice is not for \(err.expected.name)") } else if let _ = payError as? Scan.LnurlPay_Error_AlreadyPaidInvoice { diff --git a/phoenix-ios/phoenix-ios/views/send/MetadataSheet.swift b/phoenix-ios/phoenix-ios/views/send/MetadataSheet.swift index a89c96ce7..bbd10e363 100644 --- a/phoenix-ios/phoenix-ios/views/send/MetadataSheet.swift +++ b/phoenix-ios/phoenix-ios/views/send/MetadataSheet.swift @@ -16,7 +16,7 @@ struct MetadataSheet: View { let lnurlPay: LnurlPay.Intent - @Environment(\.smartModalState) var smartModalState: SmartModalState + @EnvironmentObject var smartModalState: SmartModalState @ViewBuilder var body: some View { diff --git a/phoenix-ios/phoenix-ios/views/send/MinerFeeInfo.swift b/phoenix-ios/phoenix-ios/views/send/MinerFeeInfo.swift new file mode 100644 index 000000000..b26a61a22 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/send/MinerFeeInfo.swift @@ -0,0 +1,8 @@ +import Foundation +import PhoenixShared + +struct MinerFeeInfo { + let pubKeyScript: Bitcoin_kmpByteVector + let feerate: Lightning_kmpFeeratePerKw + let minerFee: Bitcoin_kmpSatoshi +} diff --git a/phoenix-ios/phoenix-ios/views/send/MinerFeeSheet.swift b/phoenix-ios/phoenix-ios/views/send/MinerFeeSheet.swift index 849a0ad79..eb306a03a 100644 --- a/phoenix-ios/phoenix-ios/views/send/MinerFeeSheet.swift +++ b/phoenix-ios/phoenix-ios/views/send/MinerFeeSheet.swift @@ -18,13 +18,15 @@ struct MinerFeeSheet: View { let amount: Bitcoin_kmpSatoshi let btcAddress: String - @Binding var minerFeeSats: Int64? + @Binding var minerFeeInfo: MinerFeeInfo? @Binding var satsPerByte: String @Binding var parsedSatsPerByte: Result @Binding var mempoolRecommendedResponse: MempoolRecommendedResponse? + @State var explicitlySelectedPriority: MinerFeePriority? = nil + @Environment(\.colorScheme) var colorScheme: ColorScheme - @Environment(\.smartModalState) var smartModalState: SmartModalState + @EnvironmentObject var smartModalState: SmartModalState @EnvironmentObject var currencyPrefs: CurrencyPrefs enum Field: Hashable { @@ -39,13 +41,6 @@ struct MinerFeeSheet: View { ) @State var priorityBoxWidth: CGFloat? = nil - enum PriorityBoxHeight: Preference {} - let priorityBoxHeightReader = GeometryPreferenceReader( - key: AppendValue.self, - value: { [$0.size.height] } - ) - @State var priorityBoxHeight: CGFloat? = nil - // -------------------------------------------------- // MARK: View Builders // -------------------------------------------------- @@ -61,6 +56,9 @@ struct MinerFeeSheet: View { .onChange(of: satsPerByte) { _ in satsPerByteChanged() } + .onChange(of: mempoolRecommendedResponse) { _ in + mempoolRecommendedResponseChanged() + } } @ViewBuilder @@ -306,7 +304,7 @@ struct MinerFeeSheet: View { Text("Review Transaction") } .font(.title3) - .disabled(minerFeeSats == nil) + .disabled(minerFeeInfo == nil) Spacer() } .padding() @@ -338,7 +336,8 @@ struct MinerFeeSheet: View { return TextFieldNumberStyler( formatter: formatter, amount: $satsPerByte, - parsedAmount: $parsedSatsPerByte + parsedAmount: $parsedSatsPerByte, + userDidEdit: userDidEditSatsPerByteField ) } @@ -373,31 +372,46 @@ struct MinerFeeSheet: View { func isPrioritySelected(_ priority: MinerFeePriority) -> Bool { + guard let mempoolRecommendedResponse else { + return false + } - if let mempoolRecommendedResponse { - - switch parsedSatsPerByte { - case .success(let amount): - return amount.doubleValue == mempoolRecommendedResponse.feeForPriority(priority) - case .failure(_): - return false - } + if let explicitlySelectedPriority { + return explicitlySelectedPriority == priority + } - } else { + guard let amount = try? parsedSatsPerByte.get() else { return false } + + switch priority { + case .none: + return amount.doubleValue == mempoolRecommendedResponse.feeForPriority(.none) + + case .low: + return amount.doubleValue == mempoolRecommendedResponse.feeForPriority(.low) && + amount.doubleValue != mempoolRecommendedResponse.feeForPriority(.none) + + case .medium: + return amount.doubleValue == mempoolRecommendedResponse.feeForPriority(.medium) && + amount.doubleValue != mempoolRecommendedResponse.feeForPriority(.high) + + case .high: + return amount.doubleValue == mempoolRecommendedResponse.feeForPriority(.high) && + amount.doubleValue != mempoolRecommendedResponse.feeForPriority(.medium) + } } func minerFeeStrings() -> (FormattedAmount, FormattedAmount) { - guard let minerFeeSats else { + guard let minerFeeInfo else { let btc = Utils.unknownBitcoinAmount(bitcoinUnit: currencyPrefs.bitcoinUnit) let fiat = Utils.unknownFiatAmount(fiatCurrency: currencyPrefs.fiatCurrency) return (btc, fiat) } - let btc = Utils.formatBitcoin(currencyPrefs, sat: minerFeeSats) - let fiat = Utils.formatFiat(currencyPrefs, sat: minerFeeSats) + let btc = Utils.formatBitcoin(currencyPrefs, sat: minerFeeInfo.minerFee) + let fiat = Utils.formatFiat(currencyPrefs, sat: minerFeeInfo.minerFee) return (btc, fiat) } @@ -412,10 +426,17 @@ struct MinerFeeSheet: View { return } + explicitlySelectedPriority = priority parsedSatsPerByte = .success(NSNumber(value: tuple.0)) satsPerByte = tuple.1 } + func userDidEditSatsPerByteField() { + log.trace("userDidEditSatsPerByteField()") + + explicitlySelectedPriority = nil + } + func satsPerByteChanged() { log.trace("satsPerByteChanged(): \(satsPerByte)") @@ -424,7 +445,7 @@ struct MinerFeeSheet: View { let peer = Biz.business.getPeer(), let scriptBytes = Parser.shared.addressToPublicKeyScript(chain: Biz.business.chain, address: btcAddress) else { - minerFeeSats = nil + minerFeeInfo = nil return } @@ -432,9 +453,10 @@ struct MinerFeeSheet: View { let scriptVector = Bitcoin_kmpByteVector(bytes: scriptBytes) let satsPerByte_satoshi = Bitcoin_kmpSatoshi(sat: satsPerByte_number.int64Value) - let feePerKw = Lightning_kmpFeeratePerKw(feerate: satsPerByte_satoshi) + let feePerByte = Lightning_kmpFeeratePerByte(feerate: satsPerByte_satoshi) + let feePerKw = Lightning_kmpFeeratePerKw(feeratePerByte: feePerByte) - minerFeeSats = nil + minerFeeInfo = nil Task { @MainActor in do { let pair = try await peer.estimateFeeForSpliceOut( @@ -443,22 +465,28 @@ struct MinerFeeSheet: View { targetFeerate: feePerKw ) - let _: Lightning_kmpFeeratePerKw = pair!.first! + let updatedFeePerKw: Lightning_kmpFeeratePerKw = pair!.first! let fee: Bitcoin_kmpSatoshi = pair!.second! - if self.satsPerByte == originalSatsPerByte { - self.minerFeeSats = fee.sat + self.minerFeeInfo = MinerFeeInfo(pubKeyScript: scriptVector, feerate: updatedFeePerKw, minerFee: fee) } } catch { log.error("Error: \(error)") - self.minerFeeSats = nil + self.minerFeeInfo = nil } } // } + func mempoolRecommendedResponseChanged() { + log.trace("mempoolRecommendedResponseChanged()") + + // The UI will change, so we need to reset the geometry measurements + priorityBoxWidth = nil + } + func closeButtonTapped() { log.trace("closeButtonTapped()") smartModalState.close() diff --git a/phoenix-ios/phoenix-ios/views/send/PaymentLayerChoice.swift b/phoenix-ios/phoenix-ios/views/send/PaymentLayerChoice.swift index 8a2a5e979..9fde825ac 100644 --- a/phoenix-ios/phoenix-ios/views/send/PaymentLayerChoice.swift +++ b/phoenix-ios/phoenix-ios/views/send/PaymentLayerChoice.swift @@ -16,7 +16,7 @@ struct PaymentLayerChoice: View { @ObservedObject var mvi: MVIState - @Environment(\.popoverState) var popoverState: PopoverState + @EnvironmentObject var popoverState: PopoverState @ViewBuilder var body: some View { diff --git a/phoenix-ios/phoenix-ios/views/send/RangeSheet.swift b/phoenix-ios/phoenix-ios/views/send/RangeSheet.swift index 73d4aa3e2..f4cc46926 100644 --- a/phoenix-ios/phoenix-ios/views/send/RangeSheet.swift +++ b/phoenix-ios/phoenix-ios/views/send/RangeSheet.swift @@ -23,7 +23,7 @@ struct RangeSheet: View { value: { [$0.size.height] } ) - @Environment(\.smartModalState) var smartModalState: SmartModalState + @EnvironmentObject var smartModalState: SmartModalState @EnvironmentObject var currencyPrefs: CurrencyPrefs // -------------------------------------------------- diff --git a/phoenix-ios/phoenix-ios/views/send/ScanView.swift b/phoenix-ios/phoenix-ios/views/send/ScanView.swift index b15f3eaec..a85e79133 100644 --- a/phoenix-ios/phoenix-ios/views/send/ScanView.swift +++ b/phoenix-ios/phoenix-ios/views/send/ScanView.swift @@ -28,11 +28,11 @@ struct ScanView: View { @State var ignoreScanner: Bool = false @Environment(\.colorScheme) var colorScheme: ColorScheme - @Environment(\.smartModalState) var smartModalState: SmartModalState - @Environment(\.popoverState) var popoverState: PopoverState @EnvironmentObject var currencyPrefs: CurrencyPrefs @EnvironmentObject var deviceInfo: DeviceInfo + @EnvironmentObject var popoverState: PopoverState + @EnvironmentObject var smartModalState: SmartModalState let willEnterForegroundPublisher = NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification @@ -570,7 +570,7 @@ struct ManualInput: View, ViewName { @State var input = "" - @Environment(\.smartModalState) private var smartModalState: SmartModalState + @EnvironmentObject var smartModalState: SmartModalState @ViewBuilder var body: some View { diff --git a/phoenix-ios/phoenix-ios/views/send/SendView.swift b/phoenix-ios/phoenix-ios/views/send/SendView.swift index abeb7af1c..28aad0329 100644 --- a/phoenix-ios/phoenix-ios/views/send/SendView.swift +++ b/phoenix-ios/phoenix-ios/views/send/SendView.swift @@ -137,9 +137,8 @@ struct SendView: MVIView { } else if let reason = model.reason as? Scan.BadRequestReason_ChainMismatch { - let requestChain = reason.actual.name msg = NSLocalizedString( - "The invoice is for \(requestChain), but you're on \(reason.actual.name)", + "The invoice is not for \(reason.expected.name)", comment: "Error message - scanning lightning invoice" ) diff --git a/phoenix-ios/phoenix-ios/views/send/SpliceOutProblem.swift b/phoenix-ios/phoenix-ios/views/send/SpliceOutProblem.swift new file mode 100644 index 000000000..d703a0a51 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/send/SpliceOutProblem.swift @@ -0,0 +1,72 @@ +import Foundation +import PhoenixShared + +enum SpliceOutProblem: Error { + case insufficientFunds + case spliceAlreadyInProgress + case channelNotIdle + case sessionError + case disconnected + case other + + func localizedDescription() -> String { + + switch self { + case .insufficientFunds: + return String(localized: "Insufficient funds") + case .spliceAlreadyInProgress: + return String(localized: "Splice already in progress") + case .channelNotIdle: + return String(localized: "Channel not idle") + case .sessionError: + return String(localized: "Splice-out session error") + case .disconnected: + return String(localized: "Disconnected during splice-out attempt") + case .other: + return String(localized: "Unknown splice-out error") + } + } + + static func fromResponse( + _ response: Lightning_kmpChannelCommand.CommitmentSpliceResponse? + ) -> SpliceOutProblem? { + + guard let response else { + return .other + } + + guard let failure = response.asFailure() else { + return nil // not a failure + } + + if let _ = failure.asInsufficientFunds() { + return .insufficientFunds + } + if let _ = failure.asSpliceAlreadyInProgress() { + return .spliceAlreadyInProgress + } + if let _ = failure.asChannelNotIdle() { + return .channelNotIdle + } + if let _ = failure.asFundingFailure() { + return .sessionError + } + if let _ = failure.asCannotStartSession() { + return .sessionError + } + if let _ = failure.asInteractiveTxSessionFailed() { + return .sessionError + } + if let _ = failure.asCannotCreateCommitTx() { + return .sessionError + } + if let _ = failure.asAbortedByPeer() { + return .sessionError + } + if let _ = failure.asDisconnected() { + return .disconnected + } + + return .other + } +} diff --git a/phoenix-ios/phoenix-ios/views/send/TipSliderSheet.swift b/phoenix-ios/phoenix-ios/views/send/TipSliderSheet.swift index 6fa8c2e72..460c37bdd 100644 --- a/phoenix-ios/phoenix-ios/views/send/TipSliderSheet.swift +++ b/phoenix-ios/phoenix-ios/views/send/TipSliderSheet.swift @@ -42,7 +42,7 @@ struct TipSliderSheet: View { ) @State var contentHeight: CGFloat? = nil - @Environment(\.smartModalState) var smartModalState: SmartModalState + @EnvironmentObject var smartModalState: SmartModalState @EnvironmentObject var currencyPrefs: CurrencyPrefs // There are 3 scenarios: diff --git a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift index c37fd4e6c..34f12faf8 100644 --- a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift +++ b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift @@ -46,11 +46,14 @@ struct ValidateView: View { @State var altAmount: String = "" @State var problem: Problem? = nil + @State var spliceOutInProgress: Bool = false + @State var spliceOutProblem: SpliceOutProblem? = nil + @State var preTipAmountMsat: Int64? = nil @State var postTipAmountMsat: Int64? = nil @State var tipSliderSheetVisible: Bool = false - @State var minerFeeSats: Int64? = nil + @State var minerFeeInfo: MinerFeeInfo? = nil @State var satsPerByte: String = "" @State var parsedSatsPerByte: Result = Result.failure(.emptyInput) @@ -71,10 +74,6 @@ struct ValidateView: View { @StateObject var connectionsMonitor = ObservableConnectionsMonitor() - @Environment(\.popoverState) var popoverState: PopoverState - @Environment(\.smartModalState) var smartModalState: SmartModalState - @EnvironmentObject var currencyPrefs: CurrencyPrefs - // For the cicular buttons: [metadata, tip, comment] enum MaxButtonWidth: Preference {} let maxButtonWidthReader = GeometryPreferenceReader( @@ -83,6 +82,12 @@ struct ValidateView: View { ) @State var maxButtonWidth: CGFloat? = nil + @Environment(\.presentationMode) var presentationMode: Binding + + @EnvironmentObject var currencyPrefs: CurrencyPrefs + @EnvironmentObject var popoverState: PopoverState + @EnvironmentObject var smartModalState: SmartModalState + // -------------------------------------------------- // MARK: View Builders // -------------------------------------------------- @@ -152,10 +157,9 @@ struct ValidateView: View { .onAppear() { onAppear() } - .navigationStackDestination( // For iOS 16+ - isPresented: $currencyConverterOpen, - destination: currencyConverterView - ) + .navigationStackDestination(isPresented: $currencyConverterOpen) { // For iOS 16+ + currencyConverterView() + } .onChange(of: mvi.model) { newModel in modelDidChange(newModel) } @@ -172,14 +176,7 @@ struct ValidateView: View { chainContextDidChange($0) } .task { - let result = await MempoolRecommendedResponse.fetch() - switch result { - case .success(let response): - mempoolRecommendedResponse = response - case .failure(let reason): - log.error("Errror fetching mempool.space/recommended: \(reason)") - mempoolRecommendedResponse = nil - } + await fetchMempoolRecommendedFees() } } @@ -428,7 +425,7 @@ struct ValidateView: View { @ViewBuilder func paymentButton() -> some View { - let needsPrepare = (mvi.model is Scan.Model_OnChainFlow) && (minerFeeSats == nil) + let needsPrepare = (mvi.model is Scan.Model_OnChainFlow) && (minerFeeInfo == nil) Button { if needsPrepare { @@ -469,7 +466,7 @@ struct ValidateView: View { backgroundFill: Color.appAccent, disabledBackgroundFill: Color.gray )) - .disabled(problem != nil || isDisconnected || chainContext == nil) + .disabled(problem != nil || isDisconnected || chainContext == nil || spliceOutInProgress) .accessibilityHint(paymentButtonHint()) } @@ -496,6 +493,11 @@ struct ValidateView: View { Text("Unable to fetch fees") .foregroundColor(.appNegative) + + } else if let spliceOutProblem { + + Text(spliceOutProblem.localizedDescription()) + .foregroundColor(.appNegative) } } } @@ -704,6 +706,20 @@ struct ValidateView: View { } } + // -------------------------------------------------- + // MARK: Tasks + // -------------------------------------------------- + + func fetchMempoolRecommendedFees() async { + + for try await response in MempoolMonitor.shared.stream() { + mempoolRecommendedResponse = response + if Task.isCancelled { + return + } + } + } + // -------------------------------------------------- // MARK: Utilities // -------------------------------------------------- @@ -826,13 +842,18 @@ struct ValidateView: View { let lightningFeeMsat: Int64 if mvi.model is Scan.Model_OnChainFlow { lightningFeeMsat = 0 + } else if let chainContext, let trampolineFees = chainContext.trampoline.v3.first { + let p1 = Utils.toMsat(sat: trampolineFees.feeBaseSat) + let f2 = Double(trampolineFees.feePerMillionths) / 1_000_000 + let p2 = Int64(Double(recipientAmountMsat) * f2) + lightningFeeMsat = p1 + p2 } else { - lightningFeeMsat = 40_000 // Todo... + lightningFeeMsat = 0 } let minerFeeMsat: Int64 - if let minerFeeSats { - minerFeeMsat = Utils.toMsat(sat: minerFeeSats) + if let minerFeeInfo { + minerFeeMsat = Utils.toMsat(sat: minerFeeInfo.minerFee) } else { minerFeeMsat = 0 } @@ -1424,7 +1445,7 @@ struct ValidateView: View { MinerFeeSheet( amount: Bitcoin_kmpSatoshi(sat: sat), btcAddress: model.uri.address, - minerFeeSats: $minerFeeSats, + minerFeeInfo: $minerFeeInfo, satsPerByte: $satsPerByte, parsedSatsPerByte: $parsedSatsPerByte, mempoolRecommendedResponse: $mempoolRecommendedResponse @@ -1458,6 +1479,47 @@ struct ValidateView: View { trampolineFees: trampolineFees )) + } else if let _ = mvi.model as? Scan.Model_OnChainFlow { + + guard + let minerFeeInfo = minerFeeInfo, + let peer = Biz.business.getPeer(), + spliceOutInProgress == false + else { + return + } + + spliceOutInProgress = true + spliceOutProblem = nil + + let amountSat = Bitcoin_kmpSatoshi(sat: Utils.truncateToSat(msat: msat)) + Task { @MainActor in + do { + let response = try await peer.spliceOut( + amount: amountSat, + scriptPubKey: minerFeeInfo.pubKeyScript, + feerate: minerFeeInfo.feerate + ) + + self.spliceOutInProgress = false + + if let problem = SpliceOutProblem.fromResponse(response) { + self.spliceOutProblem = problem + + } else { + self.spliceOutProblem = nil + self.presentationMode.wrappedValue.dismiss() + } + + } catch { + log.error("peer.spliceOut(): error: \(error)") + + self.spliceOutInProgress = false + self.spliceOutProblem = .other + + } + } // + } else if let model = mvi.model as? Scan.Model_LnurlPayFlow_LnurlPayRequest { if showCommentButton() && comment.count == 0 && !hasPromptedForComment { diff --git a/phoenix-ios/phoenix-ios/views/style/TruncatableView.swift b/phoenix-ios/phoenix-ios/views/style/TruncatableView.swift index 0f745ce8e..02c902f31 100644 --- a/phoenix-ios/phoenix-ios/views/style/TruncatableView.swift +++ b/phoenix-ios/phoenix-ios/views/style/TruncatableView.swift @@ -11,10 +11,10 @@ struct TruncatableView: View { let content: Content let wasTruncated: () -> Void - @State private var renderedSize: [ContentSizeCategory: CGSize] = [:] - @State private var intrinsicSize: [ContentSizeCategory: CGSize] = [:] + @State private var renderedSize: [DynamicTypeSize: CGSize] = [:] + @State private var intrinsicSize: [DynamicTypeSize: CGSize] = [:] - @Environment(\.sizeCategory) private var contentSizeCategory: ContentSizeCategory + @Environment(\.dynamicTypeSize) var dynamicTypeSize: DynamicTypeSize init( fixedHorizontal: Bool, @@ -30,25 +30,25 @@ struct TruncatableView: View { @ViewBuilder var body: some View { - let csc = self.contentSizeCategory + let dts = self.dynamicTypeSize content .readSize { size in - renderedSize[csc] = size - checkForTruncation(csc) + renderedSize[dts] = size + checkForTruncation(dts) } .background( content .fixedSize(horizontal: fixedHorizontal, vertical: fixedVertical) .hidden() .readSize { size in - intrinsicSize[csc] = size - checkForTruncation(csc) + intrinsicSize[dts] = size + checkForTruncation(dts) } ) } - func checkForTruncation(_ csc: ContentSizeCategory) { - guard let rSize = renderedSize[csc], let iSize = intrinsicSize[csc] else { + func checkForTruncation(_ dts: DynamicTypeSize) { + guard let rSize = renderedSize[dts], let iSize = intrinsicSize[dts] else { return } if rSize.width < iSize.width || rSize.height < iSize.height { @@ -74,3 +74,23 @@ private struct SizePreferenceKey: PreferenceKey { static var defaultValue: CGSize = .zero static func reduce(value: inout CGSize, nextValue: () -> CGSize) {} } + +extension DynamicTypeSize: CustomStringConvertible { + public var description: String { + switch self { + case .xSmall : return "xSmall" + case .small : return "small" + case .medium : return "medium" + case .large : return "large" + case .xLarge : return "xLarge" + case .xxLarge : return "xxLarge" + case .xxxLarge : return "xxxLarge" + case .accessibility1 : return "accessibility1" + case .accessibility2 : return "accessibility2" + case .accessibility3 : return "accessibility3" + case .accessibility4 : return "accessibility4" + case .accessibility5 : return "accessibility5" + @unknown default : return "unknown" + } + } +} diff --git a/phoenix-ios/phoenix-ios/views/tools/AppStatusPopover.swift b/phoenix-ios/phoenix-ios/views/tools/AppStatusPopover.swift index 305153f33..df9a8faae 100644 --- a/phoenix-ios/phoenix-ios/views/tools/AppStatusPopover.swift +++ b/phoenix-ios/phoenix-ios/views/tools/AppStatusPopover.swift @@ -21,7 +21,7 @@ struct AppStatusPopover: View { @State var syncState: SyncTxManager_State = .initializing @State var pendingSettings: SyncTxManager_PendingSettings? = nil - @Environment(\.popoverState) var popoverState: PopoverState + @EnvironmentObject var popoverState: PopoverState let syncManager = Biz.syncManager!.syncTxManager diff --git a/phoenix-ios/phoenix-ios/views/tools/CurrencyConverterView.swift b/phoenix-ios/phoenix-ios/views/tools/CurrencyConverterView.swift index baf103a84..9622012b9 100644 --- a/phoenix-ios/phoenix-ios/views/tools/CurrencyConverterView.swift +++ b/phoenix-ios/phoenix-ios/views/tools/CurrencyConverterView.swift @@ -150,10 +150,9 @@ struct CurrencyConverterView: View { .onDisappear { onDisappear() } - .navigationStackDestination( // For iOS 16+ - isPresented: $currencySelectorOpen, - destination: currencySelectorView - ) + .navigationStackDestination(isPresented: $currencySelectorOpen) { // For iOS 16+ + currencySelectorView() + } .onChange(of: currencies) { _ in currenciesDidChange() } diff --git a/phoenix-ios/phoenix-ios/views/tools/IncomingDepositPopover.swift b/phoenix-ios/phoenix-ios/views/tools/IncomingDepositPopover.swift deleted file mode 100644 index 4ac5a83dd..000000000 --- a/phoenix-ios/phoenix-ios/views/tools/IncomingDepositPopover.swift +++ /dev/null @@ -1,257 +0,0 @@ -import SwiftUI -import PhoenixShared -import os.log - -#if DEBUG && true -fileprivate var log = Logger( - subsystem: Bundle.main.bundleIdentifier!, - category: "MinimumDepositPopover" -) -#else -fileprivate var log = Logger(OSLog.disabled) -#endif - -struct IncomingDepositPopover: View { - - let swapInWalletBalancePublisher = Biz.business.balanceManager.swapInWalletBalancePublisher() - @State var swapInWalletBalance: WalletBalance = WalletBalance.companion.empty() - - let swapInRejectedPublisher = Biz.swapInRejectedPublisher - @State var swapInRejected: Lightning_kmpLiquidityEventsRejected? = nil - - @Environment(\.popoverState) var popoverState: PopoverState - - @EnvironmentObject var deepLinkManager: DeepLinkManager - - @ViewBuilder - var body: some View { - - VStack(alignment: HorizontalAlignment.leading, spacing: 0) { - header() - content() - } - .onReceive(swapInWalletBalancePublisher) { - swapInWalletBalanceChanged($0) - } - .onReceive(swapInRejectedPublisher) { - swapInRejectedStateChanged($0) - } - } - - @ViewBuilder - func header() -> some View { - - HStack(alignment: VerticalAlignment.center, spacing: 0) { - - if showNormal() { - Text("Incoming payments") - .font(.title3) - .accessibilityAddTraits(.isHeader) - .accessibilitySortPriority(100) - } else { - Text("On-chain pending funds") - .font(.title3) - .accessibilityAddTraits(.isHeader) - .accessibilitySortPriority(100) - } - - Spacer() - - Button { - close() - } label: { - Image("ic_cross") - .resizable() - .frame(width: 30, height: 30) - } - .accessibilityLabel("Close") - .accessibilityHidden(popoverState.currentItem?.dismissable ?? false) - } - .padding(.horizontal, 15) - .padding(.vertical, 8) - .background( - Color(UIColor.secondarySystemBackground) - .cornerRadius(15, corners: [.topLeft, .topRight]) - ) - } - - @ViewBuilder - func content() -> some View { - - if showNormal() { - content_normal() - } else { - content_pendingFunds() - } - } - - @ViewBuilder - func content_normal() -> some View { - - VStack(alignment: HorizontalAlignment.leading, spacing: 15) { - - Text( - """ - These funds will appear on your balance once swapped to Lightning, according to your fee settings. - """ - ) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) // text truncation bugs - - HStack(alignment: VerticalAlignment.center, spacing: 0) { - Spacer() - Button { - navigateToLiquiditySettings() - } label: { - Text("Check fee settings") - } - } - .padding(.top, 5) - } - .padding(.all, 15) - } - - @ViewBuilder - func content_pendingFunds() -> some View { - - VStack(alignment: HorizontalAlignment.leading, spacing: 15) { - - Text( - """ - You have funds that could not be swapped to Lightning. - """ - ) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) // text truncation bugs - - if let rejected = swapInRejected, let reason = rejected.reason.asOverAbsoluteFee() { - - let actualFee = Utils.formatBitcoin(msat: rejected.fee, bitcoinUnit: .sat) - let maxFee = Utils.formatBitcoin(sat: reason.maxAbsoluteFee, bitcoinUnit: .sat) - - Text("The fee was \(actualFee.string), but your max fee was set to \(maxFee.string)") - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) // text truncation bugs - - } else if let rejected = swapInRejected, let reason = rejected.reason.asOverRelativeFee() { - - let actualFee = Utils.formatBitcoin(msat: rejected.fee, bitcoinUnit: .sat) - let percent = basisPointsAsPercent(reason.maxRelativeFeeBasisPoints) - - Text("The fee was \(actualFee.string) which is more than \(percent) of the amount.") - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) // text truncation bugs - } - - HStack(alignment: VerticalAlignment.center, spacing: 0) { - Spacer() - Button { - navigateToLiquiditySettings() - } label: { - Text("Check fee settings") - } - } - .padding(.top, 5) - } - .padding(.all, 15) - } - - // -------------------------------------------------- - // MARK: View Helpers - // -------------------------------------------------- - - func showNormal() -> Bool { - - return !showOnChainPendingFunds() - } - - func showOnChainPendingFunds() -> Bool { - - return swapInWalletBalance.confirmed.sat > 0 && - swapInWalletBalance.unconfirmed.sat == 0 && - swapInWalletBalance.weaklyConfirmed.sat == 0 && - swapInRejected != nil - } - - // -------------------------------------------------- - // MARK: Notifications - // -------------------------------------------------- - - func basisPointsAsPercent(_ basisPoints: Int32) -> String { - - // Example: 30% == 3,000 basis points - // - // 3,000 / 100 => 30.0 => 3000% - // 3,000 / 100 / 100 => 0.3 => 30% - - let percent = Double(basisPoints) / Double(10_000) - - let formatter = NumberFormatter() - formatter.numberStyle = .percent - formatter.minimumFractionDigits = 0 - formatter.maximumFractionDigits = 2 - - return formatter.string(from: NSNumber(value: percent)) ?? "?%" - } - - // -------------------------------------------------- - // MARK: Notifications - // -------------------------------------------------- - - func swapInWalletBalanceChanged(_ walletBalance: WalletBalance) { - log.trace("swapInWalletBalanceChanged()") - - swapInWalletBalance = walletBalance - } - - func swapInRejectedStateChanged(_ state: Lightning_kmpLiquidityEventsRejected?) { - log.trace("swapInRejectedStateChanged()") - - swapInRejected = state - } - - // -------------------------------------------------- - // MARK: Actions - // -------------------------------------------------- - - func navigateToLiquiditySettings() { - log.trace("navigateToLiquiditySettings()") - - popoverState.close { - self.deepLinkManager.broadcast(.liquiditySettings) - } - } - - func exploreIncomingDeposit(website: BlockchainExplorer.Website) { - log.trace("exploreIncomingDeposit()") - - guard let peer = Biz.business.getPeer() else { - return - } - let addr = peer.swapInAddress - - let txUrlStr = Biz.business.blockchainExplorer.addressUrl(addr: addr, website: website) - if let txUrl = URL(string: txUrlStr) { - UIApplication.shared.open(txUrl) - } - } - - func copySwapInAddress() { - log.trace("copySwapInAddress()") - - guard let peer = Biz.business.getPeer() else { - return - } - let addr = peer.swapInAddress - - UIPasteboard.general.string = addr - } - - func close() { - log.trace("close()") - - withAnimation { - popoverState.close() - } - } -} diff --git a/phoenix-ios/phoenix-ios/views/tools/UnlockErrorView.swift b/phoenix-ios/phoenix-ios/views/tools/UnlockErrorView.swift index 63fd74cb9..3a1f1a377 100644 --- a/phoenix-ios/phoenix-ios/views/tools/UnlockErrorView.swift +++ b/phoenix-ios/phoenix-ios/views/tools/UnlockErrorView.swift @@ -15,8 +15,8 @@ struct UnlockErrorView: View { let danger: UnlockError - @Environment(\.popoverState) private var popoverState: PopoverState - @State private var popoverItem: PopoverItem? = nil + @EnvironmentObject var popoverState: PopoverState + @State var popoverItem: PopoverItem? = nil @StateObject var toast = Toast() @@ -163,7 +163,7 @@ struct ErrorDetailsView: View, ViewName { @State var sharing: String? = nil @Environment(\.colorScheme) var colorScheme: ColorScheme - @Environment(\.popoverState) var popoverState: PopoverState + @EnvironmentObject var popoverState: PopoverState enum ButtonHeight: Preference {} let buttonHeightReader = GeometryPreferenceReader( diff --git a/phoenix-ios/phoenix-ios/views/transactions/TransactionsView.swift b/phoenix-ios/phoenix-ios/views/transactions/TransactionsView.swift index fb9628b27..bc91b398c 100644 --- a/phoenix-ios/phoenix-ios/views/transactions/TransactionsView.swift +++ b/phoenix-ios/phoenix-ios/views/transactions/TransactionsView.swift @@ -37,6 +37,7 @@ struct TransactionsView: View { @State var isDownloadingTxs: Bool = false @State var didAppear = false + @State var popToDestination: PopToDestination? = nil @Environment(\.colorScheme) var colorScheme @@ -75,10 +76,9 @@ struct TransactionsView: View { .navigationTitle(NSLocalizedString("Payments", comment: "Navigation bar title")) .navigationBarTitleDisplayMode(.inline) .navigationBarItems(trailing: exportButton()) - .navigationStackDestination( // For iOS 16+ - isPresented: navLinkBinding(), - destination: navLinkView - ) + .navigationStackDestination(isPresented: navLinkBinding()) { // For iOS 16+ + navLinkView() + } } @ViewBuilder @@ -201,7 +201,7 @@ struct TransactionsView: View { if let selectedItem { PaymentView( - type: .embedded, + type: .embedded(popTo: popTo), paymentInfo: selectedItem ) @@ -235,10 +235,29 @@ struct TransactionsView: View { // Careful: this function is also called when returning from subviews if !didAppear { didAppear = true + paymentsPageFetcher.subscribeToAll( offset: 0, count: Int32(PAGE_COUNT_START) ) + + } else { + + if let destination = popToDestination { + log.debug("popToDestination: \(destination)") + + popToDestination = nil + switch destination { + case .RootView(_): + log.debug("Unhandled popToDestination") + + case .ConfigurationView(_): + log.debug("Invalid popToDestination") + + case .TransactionsView: + log.debug("At destination") + } + } } if !deviceInfo.isIPad { @@ -546,6 +565,12 @@ struct TransactionsView: View { // MARK: Actions // -------------------------------------------------- + func popTo(_ destination: PopToDestination) { + log.trace("popTo(\(destination))") + + popToDestination = destination + } + func didSelectPayment(row: WalletPaymentOrderRow) -> Void { log.trace("didSelectPayment()") diff --git a/phoenix-legacy/build.gradle.kts b/phoenix-legacy/build.gradle.kts index 1c87b0c10..8f9f723c2 100644 --- a/phoenix-legacy/build.gradle.kts +++ b/phoenix-legacy/build.gradle.kts @@ -31,7 +31,7 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { - val libCode = 54 + val libCode = 55 getByName("debug") { resValue("string", "CHAIN", chain) buildConfigField("String", "CHAIN", chain) diff --git a/phoenix-legacy/src/main/res/values-cs/strings.xml b/phoenix-legacy/src/main/res/values-cs/strings.xml index 2c6532761..f5bf8d6cf 100644 --- a/phoenix-legacy/src/main/res/values-cs/strings.xml +++ b/phoenix-legacy/src/main/res/values-cs/strings.xml @@ -420,7 +420,7 @@ Phoenix upgrade - Upgrade na nejnovฤ›jลกรญ verzi Phoenixu se ลกpiฤkovรฝm Lightning enginem a novรฝmi funkcemi.\n\nVaลกe kanรกly budou slouฤeny dohromady. Tento proces je automatizovanรฝ. + Upgrade na nejnovฤ›jลกรญ verzi Phoenixu se ลกpiฤkovรฝm Lightning enginem a novรฝmi funkcemi.\n\nVaลกe kanรกly budou slouฤeny dohromady. Tento proces je automatizovanรฝ.\n\nPo dokonฤenรญ migrace jiลพ nepouลพรญvejte starรฉ vรฝmฤ›nnรฉ adresy. Upgrade Pล™ipojenรญโ€ฆ Teฤ ne diff --git a/phoenix-legacy/src/main/res/values-de/strings.xml b/phoenix-legacy/src/main/res/values-de/strings.xml index 719161b2b..b0b761d44 100644 --- a/phoenix-legacy/src/main/res/values-de/strings.xml +++ b/phoenix-legacy/src/main/res/values-de/strings.xml @@ -818,7 +818,7 @@ Phoenix Upgrade - Upgrade auf die neueste Version von Phoenix, mit einer hochmodernen Lightning-Engine und neuen Funktionen.\n\nIhre Kanรคle werden zusammengefรผhrt. Dieser Prozess ist automatisiert. + Upgrade auf die neueste Version von Phoenix, mit einer hochmodernen Lightning-Engine und neuen Funktionen.\n\nIhre Kanรคle werden zusammengefรผhrt. Dieser Prozess ist automatisiert.\n\nWenn die Migration abgeschlossen ist, sollten Sie Ihre alten Swap-Adressen nicht wieder verwenden. Upgrade Verbindung herstellenโ€ฆ Nicht jetzt diff --git a/phoenix-legacy/src/main/res/values-es/strings.xml b/phoenix-legacy/src/main/res/values-es/strings.xml index 61cf4cfa1..0d95aca9f 100644 --- a/phoenix-legacy/src/main/res/values-es/strings.xml +++ b/phoenix-legacy/src/main/res/values-es/strings.xml @@ -464,7 +464,7 @@ Actualizaciรณn de Phoenix - Actualรญzate a la รบltima versiรณn de Phoenix, con un motor Lightning de รบltima generaciรณn y nuevas funciones.\n\nSus canales se fusionarรกn. Este proceso estรก automatizado. + Actualรญzate a la รบltima versiรณn de Phoenix, con un motor Lightning de รบltima generaciรณn y nuevas funciones.\n\nSus canales se fusionarรกn. Este proceso estรก automatizado.\n\nUna vez finalizada la migraciรณn, no vuelva a utilizar sus antiguas direcciones de intercambio. Actualizar Connectingโ€ฆ Ahora no diff --git a/phoenix-legacy/src/main/res/values-fr/strings.xml b/phoenix-legacy/src/main/res/values-fr/strings.xml index 4e98649a9..f28fee975 100644 --- a/phoenix-legacy/src/main/res/values-fr/strings.xml +++ b/phoenix-legacy/src/main/res/values-fr/strings.xml @@ -693,7 +693,7 @@ Mise ร  niveau de Phoenix - Passez ร  la nouvelle version de Phoenix, utilisant un moteur Lightning de derniรจre gรฉnรฉration et de nouvelles fonctionnalitรฉs.\n\nLes canaux existants seront fusionnรฉs. Ce processus de migration est automatisรฉ. + Passez ร  la nouvelle version de Phoenix, utilisant un moteur Lightning de derniรจre gรฉnรฉration et de nouvelles fonctionnalitรฉs.\n\nLes canaux existants seront fusionnรฉs. Ce processus de migration est automatisรฉ.\n\nUne fois la migration faite, ne rรฉutilisez pas vos anciennes adresses de swap. Mettre ร  jour Connexionโ€ฆ Plus tard diff --git a/phoenix-legacy/src/main/res/values-pt-rBR/strings.xml b/phoenix-legacy/src/main/res/values-pt-rBR/strings.xml index b7c53ad37..6a9c9052b 100644 --- a/phoenix-legacy/src/main/res/values-pt-rBR/strings.xml +++ b/phoenix-legacy/src/main/res/values-pt-rBR/strings.xml @@ -754,7 +754,7 @@ Atualizaรงรฃo do Phoenix - Atualize para a versรฃo mais recente do Phoenix, com um mecanismo Lightning de ponta e novos recursos.\n\nSeus canais serรฃo mesclados. Esse processo รฉ automatizado. + Atualize para a versรฃo mais recente do Phoenix, com um mecanismo Lightning de ponta e novos recursos.\n\nSeus canais serรฃo mesclados. Esse processo รฉ automatizado.\n\nApรณs a conclusรฃo da migraรงรฃo, nรฃo reutilize seus endereรงos de troca antigos. Upgrade Conectandoโ€ฆ Nรฃo agora diff --git a/phoenix-legacy/src/main/res/values/strings.xml b/phoenix-legacy/src/main/res/values/strings.xml index c07722a7e..200925dc2 100644 --- a/phoenix-legacy/src/main/res/values/strings.xml +++ b/phoenix-legacy/src/main/res/values/strings.xml @@ -793,7 +793,7 @@ legacy_ Phoenix upgrade - Upgrade to the latest version of Phoenix, with a cutting-edge Lightning engine and new features.\n\nYour channels will be merged together. This process is automated. + Upgrade to the latest version of Phoenix, with a cutting-edge Lightning engine and new features.\n\nYour channels will be merged together. This process is automated.\n\nAfter migration is complete, do not re-use your old swap-in addresses. Upgrade Connectingโ€ฆ Not now diff --git a/phoenix-shared/src/androidMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt b/phoenix-shared/src/androidMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt index 32c330f64..89b6fc53c 100644 --- a/phoenix-shared/src/androidMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt +++ b/phoenix-shared/src/androidMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt @@ -3,7 +3,7 @@ package fr.acinq.phoenix.db import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.db.payments.CloudKitInterface -actual fun didCompleteWalletPayment(id: WalletPaymentId, database: PaymentsDatabase) {} +actual fun didSaveWalletPayment(id: WalletPaymentId, database: PaymentsDatabase) {} actual fun didDeleteWalletPayment(id: WalletPaymentId, database: PaymentsDatabase) {} actual fun didUpdateWalletPaymentMetadata(id: WalletPaymentId, database: PaymentsDatabase) {} diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/AppConfiguration.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/AppConfiguration.kt index c12032084..d02b6d744 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/AppConfiguration.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/AppConfiguration.kt @@ -9,11 +9,14 @@ import fr.acinq.lightning.wire.InitTlv import kotlinx.serialization.Serializable -interface CurrencyUnit +interface CurrencyUnit { + /** Code that should be displayed in the UI. */ + val displayCode: String +} @Serializable -enum class BitcoinUnit : CurrencyUnit { - Sat, Bit, MBtc, Btc; +enum class BitcoinUnit(override val displayCode: String) : CurrencyUnit { + Sat("sat"), Bit("bit"), MBtc("mbtc"), Btc("btc"); override fun toString(): String { return super.toString().lowercase() @@ -21,165 +24,176 @@ enum class BitcoinUnit : CurrencyUnit { companion object { val values = values().toList() + + fun valueOfOrNull(code: String): BitcoinUnit? = try { + valueOf(code) + } catch (e: Exception) { + null + } } + + } +/** + * @param flag when multiple countries use that currency, use the flag of the country with highest GDP + */ @Serializable -enum class FiatCurrency : CurrencyUnit { - AED, // United Arab Emirates Dirham - AFN, // Afghan Afghani - ALL, // Albanian Lek - AMD, // Armenian Dram - ANG, // Netherlands Antillean Guilder - AOA, // Angolan Kwanza - ARS, // Argentine Peso - ARS_BM, // Argentine Peso (blue market) - AUD, // Australian Dollar - AWG, // Aruban Florin - AZN, // Azerbaijani Manat - BAM, // Bosnia-Herzegovina Convertible Mark - BBD, // Barbadian Dollar - BDT, // Bangladeshi Taka - BGN, // Bulgarian Lev - BHD, // Bahraini Dinar - BIF, // Burundian Franc - BMD, // Bermudan Dollar - BND, // Brunei Dollar - BOB, // Bolivian Boliviano - BRL, // Brazilian Real - BSD, // Bahamian Dollar - BTN, // Bhutanese Ngultrum - BWP, // Botswanan Pula - BZD, // Belize Dollar - CAD, // Canadian Dollar - CDF, // Congolese Franc - CHF, // Swiss Franc - CLP, // Chilean Peso - CNH, // Chinese Yuan (offshore) - CNY, // Chinese Yuan (onshore) - COP, // Colombian Peso - CRC, // Costa Rican Colรณn - CUP, // Cuban Peso - CUP_FM, // Cuban Peso (free market) - CVE, // Cape Verdean Escudo - CZK, // Czech Republic Koruna - DJF, // Djiboutian Franc - DKK, // Danish Krone - DOP, // Dominican Peso - DZD, // Algerian Dinar - EGP, // Egyptian Pound - ERN, // Eritrean Nakfa - ETB, // Ethiopian Birr - EUR, // Euro - FJD, // Fijian Dollar - FKP, // Falkland Islands Pound - GBP, // British Pound Sterling - GEL, // Georgian Lari - GHS, // Ghanaian Cedi - GIP, // Gibraltar Pound - GMD, // Gambian Dalasi - GNF, // Guinean Franc - GTQ, // Guatemalan Quetzal - GYD, // Guyanaese Dollar - HKD, // Hong Kong Dollar - HNL, // Honduran Lempira - HRK, // Croatian Kuna - HTG, // Haitian Gourde - HUF, // Hungarian Forint - IDR, // Indonesian Rupiah - ILS, // Israeli New Sheqel - INR, // Indian Rupee - IQD, // Iraqi Dinar - IRR, // Iranian Rial - ISK, // Icelandic Krรณna - JEP, // Jersey Pound - JMD, // Jamaican Dollar - JOD, // Jordanian Dinar - JPY, // Japanese Yen - KES, // Kenyan Shilling - KGS, // Kyrgystani Som - KHR, // Cambodian Riel - KMF, // Comorian Franc - KPW, // North Korean Won - KRW, // South Korean Won - KWD, // Kuwaiti Dinar - KYD, // Cayman Islands Dollar - KZT, // Kazakhstani Tenge - LAK, // Laotian Kip - LBP, // Lebanese Pound - LBP_BM, // Lebanese Pound (black market) - LKR, // Sri Lankan Rupee - LRD, // Liberian Dollar - LSL, // Lesotho Loti - LYD, // Libyan Dinar - MAD, // Moroccan Dirham - MDL, // Moldovan Leu - MGA, // Malagasy Ariary - MKD, // Macedonian Denar - MMK, // Myanma Kyat - MNT, // Mongolian Tugrik - MOP, // Macanese Pataca - MUR, // Mauritian Rupee - MVR, // Maldivian Rufiyaa - MWK, // Malawian Kwacha - MXN, // Mexican Peso - MYR, // Malaysian Ringgit - MZN, // Mozambican Metical - NAD, // Namibian Dollar - NGN, // Nigerian Naira - NIO, // Nicaraguan Cรณrdoba - NOK, // Norwegian Krone - NPR, // Nepalese Rupee - NZD, // New Zealand Dollar - OMR, // Omani Rial - PAB, // Panamanian Balboa - PEN, // Peruvian Sol - PGK, // Papua New Guinean Kina - PHP, // Philippine Peso - PKR, // Pakistani Rupee - PLN, // Polish Zloty - PYG, // Paraguayan Guarani - QAR, // Qatari Rial - RON, // Romanian Leu - RSD, // Serbian Dinar - RUB, // Russian Ruble - RWF, // Rwandan Franc - SAR, // Saudi Riyal - SBD, // Solomon Islands Dollar - SCR, // Seychellois Rupee - SDG, // Sudanese Pound - SEK, // Swedish Krona - SGD, // Singapore Dollar - SHP, // Saint Helena Pound - SLL, // Sierra Leonean Leone - SOS, // Somali Shilling - SRD, // Surinamese Dollar - SYP, // Syrian Pound - SZL, // Swazi Lilangeni - THB, // Thai Baht - TJS, // Tajikistani Somoni - TMT, // Turkmenistani Manat - TND, // Tunisian Dinar - TOP, // Tongan Paสปanga - TRY, // Turkish Lira - TTD, // Trinidad and Tobago Dollar - TWD, // Taiwan Dollar - TZS, // Tanzanian Shilling - UAH, // Ukrainian Hryvnia - UGX, // Ugandan Shilling - USD, // United States Dollar - UYU, // Uruguayan Peso - UZS, // Uzbekistan Som - VND, // Vietnamese Dong - VUV, // Vanuatu Vatu - WST, // Samoan Tala - XAF, // CFA Franc BEAC - XCD, // East Caribbean Dollar - XOF, // CFA Franc BCEAO - XPF, // CFP Franc - YER, // Yemeni Rial - ZAR, // South African Rand - ZMW; // Zambian Kwacha +enum class FiatCurrency(override val displayCode: String, val flag: String = "๐Ÿณ๏ธ") : CurrencyUnit { + AED(displayCode = "AED", flag = "๐Ÿ‡ฆ๐Ÿ‡ช"), // United Arab Emirates Dirham + AFN(displayCode = "AFN", flag = "๐Ÿ‡ฆ๐Ÿ‡ซ"), // Afghan Afghani + ALL(displayCode = "ALL", flag = "๐Ÿ‡ฆ๐Ÿ‡ฑ"), // Albanian Lek + AMD(displayCode = "AMD", flag = "๐Ÿ‡ฆ๐Ÿ‡ฒ"), // Armenian Dram + ANG(displayCode = "ANG", flag = "๐Ÿ‡ณ๐Ÿ‡ฑ"), // Netherlands Antillean Guilder + AOA(displayCode = "AOA", flag = "๐Ÿ‡ฆ๐Ÿ‡ด"), // Angolan Kwanza + ARS_BM(displayCode = "ARS", flag = "๐Ÿ‡ฆ๐Ÿ‡ท"), // Argentine Peso (blue market) + ARS(displayCode = "ARS_OFF", flag = "๐Ÿ‡ฆ๐Ÿ‡ท"), // Argentine Peso (official rate) + AUD(displayCode = "AUD", flag = "๐Ÿ‡ฆ๐Ÿ‡บ"), // Australian Dollar + AWG(displayCode = "AWG", flag = "๐Ÿ‡ฆ๐Ÿ‡ผ"), // Aruban Florin + AZN(displayCode = "AZN", flag = "๐Ÿ‡ฆ๐Ÿ‡ฟ"), // Azerbaijani Manat + BAM(displayCode = "BAM", flag = "๐Ÿ‡ง๐Ÿ‡ฆ"), // Bosnia-Herzegovina Convertible Mark + BBD(displayCode = "BBD", flag = "๐Ÿ‡ง๐Ÿ‡ง"), // Barbadian Dollar + BDT(displayCode = "BDT", flag = "๐Ÿ‡ง๐Ÿ‡ฉ"), // Bangladeshi Taka + BGN(displayCode = "BGN", flag = "๐Ÿ‡ง๐Ÿ‡ฌ"), // Bulgarian Lev + BHD(displayCode = "BHD", flag = "๐Ÿ‡ง๐Ÿ‡ญ"), // Bahraini Dinar + BIF(displayCode = "BIF", flag = "๐Ÿ‡ง๐Ÿ‡ฎ"), // Burundian Franc + BMD(displayCode = "BMD", flag = "๐Ÿ‡ง๐Ÿ‡ฒ"), // Bermudan Dollar + BND(displayCode = "BND", flag = "๐Ÿ‡ง๐Ÿ‡ณ"), // Brunei Dollar + BOB(displayCode = "BOB", flag = "๐Ÿ‡ง๐Ÿ‡ด"), // Bolivian Boliviano + BRL(displayCode = "BRL", flag = "๐Ÿ‡ง๐Ÿ‡ท"), // Brazilian Real + BSD(displayCode = "BSD", flag = "๐Ÿ‡ง๐Ÿ‡ธ"), // Bahamian Dollar + BTN(displayCode = "BTN", flag = "๐Ÿ‡ง๐Ÿ‡น"), // Bhutanese Ngultrum + BWP(displayCode = "BWP", flag = "๐Ÿ‡ง๐Ÿ‡ผ"), // Botswanan Pula + BZD(displayCode = "BZD", flag = "๐Ÿ‡ง๐Ÿ‡ฟ"), // Belize Dollar + CAD(displayCode = "CAD", flag = "๐Ÿ‡จ๐Ÿ‡ฆ"), // Canadian Dollar + CDF(displayCode = "CDF", flag = "๐Ÿ‡จ๐Ÿ‡ฉ"), // Congolese Franc + CHF(displayCode = "CHF", flag = "๐Ÿ‡จ๐Ÿ‡ญ"), // Swiss Franc + CLP(displayCode = "CLP", flag = "๐Ÿ‡จ๐Ÿ‡ฑ"), // Chilean Peso + CNH(displayCode = "CNH", flag = "๐Ÿ‡จ๐Ÿ‡ณ"), // Chinese Yuan (offshore) + CNY(displayCode = "CNY", flag = "๐Ÿ‡จ๐Ÿ‡ณ"), // Chinese Yuan (onshore) + COP(displayCode = "COP", flag = "๐Ÿ‡จ๐Ÿ‡ด"), // Colombian Peso + CRC(displayCode = "CRC", flag = "๐Ÿ‡จ๐Ÿ‡ท"), // Costa Rican Colรณn + CUP_FM(displayCode = "CUP", flag = "๐Ÿ‡จ๐Ÿ‡บ"), // Cuban Peso (free market) + CUP(displayCode = "CUP_OFF", flag = "๐Ÿ‡จ๐Ÿ‡บ"), // Cuban Peso (official rate) + CVE(displayCode = "CVE", flag = "๐Ÿ‡จ๐Ÿ‡ป"), // Cape Verdean Escudo + CZK(displayCode = "CZK", flag = "๐Ÿ‡จ๐Ÿ‡ฟ"), // Czech Republic Koruna + DJF(displayCode = "DJF", flag = "๐Ÿ‡ฉ๐Ÿ‡ฏ"), // Djiboutian Franc + DKK(displayCode = "DKK", flag = "๐Ÿ‡ฉ๐Ÿ‡ฐ"), // Danish Krone + DOP(displayCode = "DOP", flag = "๐Ÿ‡ฉ๐Ÿ‡ด"), // Dominican Peso + DZD(displayCode = "DZD", flag = "๐Ÿ‡ฉ๐Ÿ‡ฟ"), // Algerian Dinar + EGP(displayCode = "EGP", flag = "๐Ÿ‡ช๐Ÿ‡ฌ"), // Egyptian Pound + ERN(displayCode = "ERN", flag = "๐Ÿ‡ช๐Ÿ‡ท"), // Eritrean Nakfa + ETB(displayCode = "ETB", flag = "๐Ÿ‡ช๐Ÿ‡น"), // Ethiopian Birr + EUR(displayCode = "EUR", flag = "๐Ÿ‡ช๐Ÿ‡บ"), // Euro + FJD(displayCode = "FJD", flag = "๐Ÿ‡ซ๐Ÿ‡ฏ"), // Fijian Dollar + FKP(displayCode = "FKP", flag = "๐Ÿ‡ซ๐Ÿ‡ฐ"), // Falkland Islands Pound + GBP(displayCode = "GBP", flag = "๐Ÿ‡ฌ๐Ÿ‡ง"), // British Pound Sterling + GEL(displayCode = "GEL", flag = "๐Ÿ‡ฌ๐Ÿ‡ช"), // Georgian Lari + GHS(displayCode = "GHS", flag = "๐Ÿ‡ฌ๐Ÿ‡ญ"), // Ghanaian Cedi + GIP(displayCode = "GIP", flag = "๐Ÿ‡ฌ๐Ÿ‡ฎ"), // Gibraltar Pound + GMD(displayCode = "GMD", flag = "๐Ÿ‡ฌ๐Ÿ‡ฒ"), // Gambian Dalasi + GNF(displayCode = "GNF", flag = "๐Ÿ‡ฌ๐Ÿ‡ณ"), // Guinean Franc + GTQ(displayCode = "GTQ", flag = "๐Ÿ‡ฌ๐Ÿ‡น"), // Guatemalan Quetzal + GYD(displayCode = "GYD", flag = "๐Ÿ‡ฌ๐Ÿ‡พ"), // Guyanaese Dollar + HKD(displayCode = "HKD", flag = "๐Ÿ‡ญ๐Ÿ‡ฐ"), // Hong Kong Dollar + HNL(displayCode = "HNL", flag = "๐Ÿ‡ญ๐Ÿ‡ณ"), // Honduran Lempira + HRK(displayCode = "HRK", flag = "๐Ÿ‡ญ๐Ÿ‡ท"), // Croatian Kuna + HTG(displayCode = "HTG", flag = "๐Ÿ‡ญ๐Ÿ‡น"), // Haitian Gourde + HUF(displayCode = "HUF", flag = "๐Ÿ‡ญ๐Ÿ‡บ"), // Hungarian Forint + IDR(displayCode = "IDR", flag = "๐Ÿ‡ฎ๐Ÿ‡ฉ"), // Indonesian Rupiah + ILS(displayCode = "ILS", flag = "๐Ÿ‡ฎ๐Ÿ‡ฑ"), // Israeli New Sheqel + INR(displayCode = "INR", flag = "๐Ÿ‡ฎ๐Ÿ‡ณ"), // Indian Rupee + IQD(displayCode = "IQD", flag = "๐Ÿ‡ฎ๐Ÿ‡ถ"), // Iraqi Dinar + IRR(displayCode = "IRR", flag = "๐Ÿ‡ฎ๐Ÿ‡ท"), // Iranian Rial + ISK(displayCode = "ISK", flag = "๐Ÿ‡ฎ๐Ÿ‡ธ"), // Icelandic Krรณna + JEP(displayCode = "JEP", flag = "๐Ÿ‡ฏ๐Ÿ‡ช"), // Jersey Pound + JMD(displayCode = "JMD", flag = "๐Ÿ‡ฏ๐Ÿ‡ฒ"), // Jamaican Dollar + JOD(displayCode = "JOD", flag = "๐Ÿ‡ฏ๐Ÿ‡ด"), // Jordanian Dinar + JPY(displayCode = "JPY", flag = "๐Ÿ‡ฏ๐Ÿ‡ต"), // Japanese Yen + KES(displayCode = "KES", flag = "๐Ÿ‡ฐ๐Ÿ‡ช"), // Kenyan Shilling + KGS(displayCode = "KGS", flag = "๐Ÿ‡ฐ๐Ÿ‡ฌ"), // Kyrgystani Som + KHR(displayCode = "KHR", flag = "๐Ÿ‡ฐ๐Ÿ‡ญ"), // Cambodian Riel + KMF(displayCode = "KMF", flag = "๐Ÿ‡ฐ๐Ÿ‡ฒ"), // Comorian Franc + KPW(displayCode = "KPW", flag = "๐Ÿ‡ฐ๐Ÿ‡ต"), // North Korean Won + KRW(displayCode = "KRW", flag = "๐Ÿ‡ฐ๐Ÿ‡ท"), // South Korean Won + KWD(displayCode = "KWD", flag = "๐Ÿ‡ฐ๐Ÿ‡ผ"), // Kuwaiti Dinar + KYD(displayCode = "KYD", flag = "๐Ÿ‡ฐ๐Ÿ‡พ"), // Cayman Islands Dollar + KZT(displayCode = "KZT", flag = "๐Ÿ‡ฐ๐Ÿ‡ฟ"), // Kazakhstani Tenge + LAK(displayCode = "LAK", flag = "๐Ÿ‡ฑ๐Ÿ‡ฆ"), // Laotian Kip + LBP_BM(displayCode = "LBP", flag = "๐Ÿ‡ฑ๐Ÿ‡ง"), // Lebanese Pound (black market) + LBP(displayCode = "LBP_OFF", flag = "๐Ÿ‡ฑ๐Ÿ‡ง"), // Lebanese Pound (official rate) + LKR(displayCode = "LKR", flag = "๐Ÿ‡ฑ๐Ÿ‡ฐ"), // Sri Lankan Rupee + LRD(displayCode = "LRD", flag = "๐Ÿ‡ฑ๐Ÿ‡ท"), // Liberian Dollar + LSL(displayCode = "LSL", flag = "๐Ÿ‡ฑ๐Ÿ‡ธ"), // Lesotho Loti + LYD(displayCode = "LYD", flag = "๐Ÿ‡ฑ๐Ÿ‡พ"), // Libyan Dinar + MAD(displayCode = "MAD", flag = "๐Ÿ‡ฒ๐Ÿ‡ฆ"), // Moroccan Dirham + MDL(displayCode = "MDL", flag = "๐Ÿ‡ฒ๐Ÿ‡ฉ"), // Moldovan Leu + MGA(displayCode = "MGA", flag = "๐Ÿ‡ฒ๐Ÿ‡ฌ"), // Malagasy Ariary + MKD(displayCode = "MKD", flag = "๐Ÿ‡ฒ๐Ÿ‡ฐ"), // Macedonian Denar + MMK(displayCode = "MMK", flag = "๐Ÿ‡ฒ๐Ÿ‡ฒ"), // Myanma Kyat + MNT(displayCode = "MNT", flag = "๐Ÿ‡ฒ๐Ÿ‡ณ"), // Mongolian Tugrik + MOP(displayCode = "MOP", flag = "๐Ÿ‡ฒ๐Ÿ‡ด"), // Macanese Pataca + MUR(displayCode = "MUR", flag = "๐Ÿ‡ฒ๐Ÿ‡บ"), // Mauritian Rupee + MVR(displayCode = "MVR", flag = "๐Ÿ‡ฒ๐Ÿ‡ป"), // Maldivian Rufiyaa + MWK(displayCode = "MWK", flag = "๐Ÿ‡ฒ๐Ÿ‡ผ"), // Malawian Kwacha + MXN(displayCode = "MXN", flag = "๐Ÿ‡ฒ๐Ÿ‡ฝ"), // Mexican Peso + MYR(displayCode = "MYR", flag = "๐Ÿ‡ฒ๐Ÿ‡พ"), // Malaysian Ringgit + MZN(displayCode = "MZN", flag = "๐Ÿ‡ฒ๐Ÿ‡ฟ"), // Mozambican Metical + NAD(displayCode = "NAD", flag = "๐Ÿ‡ณ๐Ÿ‡ฆ"), // Namibian Dollar + NGN(displayCode = "NGN", flag = "๐Ÿ‡ณ๐Ÿ‡ฌ"), // Nigerian Naira + NIO(displayCode = "NIO", flag = "๐Ÿ‡ณ๐Ÿ‡ฎ"), // Nicaraguan Cรณrdoba + NOK(displayCode = "NOK", flag = "๐Ÿ‡ณ๐Ÿ‡ด"), // Norwegian Krone + NPR(displayCode = "NPR", flag = "๐Ÿ‡ณ๐Ÿ‡ต"), // Nepalese Rupee + NZD(displayCode = "NZD", flag = "๐Ÿ‡ณ๐Ÿ‡ฟ"), // New Zealand Dollar + OMR(displayCode = "OMR", flag = "๐Ÿ‡ด๐Ÿ‡ฒ"), // Omani Rial + PAB(displayCode = "PAB", flag = "๐Ÿ‡ต๐Ÿ‡ฆ"), // Panamanian Balboa + PEN(displayCode = "PEN", flag = "๐Ÿ‡ต๐Ÿ‡ช"), // Peruvian Sol + PGK(displayCode = "PGK", flag = "๐Ÿ‡ต๐Ÿ‡ฌ"), // Papua New Guinean Kina + PHP(displayCode = "PHP", flag = "๐Ÿ‡ต๐Ÿ‡ญ"), // Philippine Peso + PKR(displayCode = "PKR", flag = "๐Ÿ‡ต๐Ÿ‡ฐ"), // Pakistani Rupee + PLN(displayCode = "PLN", flag = "๐Ÿ‡ต๐Ÿ‡ฑ"), // Polish Zloty + PYG(displayCode = "PYG", flag = "๐Ÿ‡ต๐Ÿ‡พ"), // Paraguayan Guarani + QAR(displayCode = "QAR", flag = "๐Ÿ‡ถ๐Ÿ‡ฆ"), // Qatari Rial + RON(displayCode = "RON", flag = "๐Ÿ‡ท๐Ÿ‡ด"), // Romanian Leu + RSD(displayCode = "RSD", flag = "๐Ÿ‡ท๐Ÿ‡ธ"), // Serbian Dinar + RUB(displayCode = "RUB", flag = "๐Ÿ‡ท๐Ÿ‡บ"), // Russian Ruble + RWF(displayCode = "RWF", flag = "๐Ÿ‡ท๐Ÿ‡ผ"), // Rwandan Franc + SAR(displayCode = "SAR", flag = "๐Ÿ‡ธ๐Ÿ‡ฆ"), // Saudi Riyal + SBD(displayCode = "SBD", flag = "๐Ÿ‡ธ๐Ÿ‡ง"), // Solomon Islands Dollar + SCR(displayCode = "SCR", flag = "๐Ÿ‡ธ๐Ÿ‡จ"), // Seychellois Rupee + SDG(displayCode = "SDG", flag = "๐Ÿ‡ธ๐Ÿ‡ฉ"), // Sudanese Pound + SEK(displayCode = "SEK", flag = "๐Ÿ‡ธ๐Ÿ‡ช"), // Swedish Krona + SGD(displayCode = "SGD", flag = "๐Ÿ‡ธ๐Ÿ‡ฌ"), // Singapore Dollar + SHP(displayCode = "SHP", flag = "๐Ÿ‡ธ๐Ÿ‡ญ"), // Saint Helena Pound + SLL(displayCode = "SLL", flag = "๐Ÿ‡ธ๐Ÿ‡ฑ"), // Sierra Leonean Leone + SOS(displayCode = "SOS", flag = "๐Ÿ‡ธ๐Ÿ‡ด"), // Somali Shilling + SRD(displayCode = "SRD", flag = "๐Ÿ‡ธ๐Ÿ‡ท"), // Surinamese Dollar + SYP(displayCode = "SYP", flag = "๐Ÿ‡ธ๐Ÿ‡พ"), // Syrian Pound + SZL(displayCode = "SZL", flag = "๐Ÿ‡ธ๐Ÿ‡ฟ"), // Swazi Lilangeni + THB(displayCode = "THB", flag = "๐Ÿ‡น๐Ÿ‡ญ"), // Thai Baht + TJS(displayCode = "TJS", flag = "๐Ÿ‡น๐Ÿ‡ฏ"), // Tajikistani Somoni + TMT(displayCode = "TMT", flag = "๐Ÿ‡น๐Ÿ‡ฒ"), // Turkmenistani Manat + TND(displayCode = "TND", flag = "๐Ÿ‡น๐Ÿ‡ณ"), // Tunisian Dinar + TOP(displayCode = "TOP", flag = "๐Ÿ‡น๐Ÿ‡ด"), // Tongan Paสปanga + TRY(displayCode = "TRY", flag = "๐Ÿ‡น๐Ÿ‡ท"), // Turkish Lira + TTD(displayCode = "TTD", flag = "๐Ÿ‡น๐Ÿ‡น"), // Trinidad and Tobago Dollar + TWD(displayCode = "TWD", flag = "๐Ÿ‡น๐Ÿ‡ผ"), // Taiwan Dollar + TZS(displayCode = "TZS", flag = "๐Ÿ‡น๐Ÿ‡ฟ"), // Tanzanian Shilling + UAH(displayCode = "UAH", flag = "๐Ÿ‡บ๐Ÿ‡ฆ"), // Ukrainian Hryvnia + UGX(displayCode = "UGX", flag = "๐Ÿ‡บ๐Ÿ‡ฌ"), // Ugandan Shilling + USD(displayCode = "USD", flag = "๐Ÿ‡บ๐Ÿ‡ธ"), // United States Dollar + UYU(displayCode = "UYU", flag = "๐Ÿ‡บ๐Ÿ‡พ"), // Uruguayan Peso + UZS(displayCode = "UZS", flag = "๐Ÿ‡บ๐Ÿ‡ฟ"), // Uzbekistan Som + VND(displayCode = "VND", flag = "๐Ÿ‡ป๐Ÿ‡ณ"), // Vietnamese Dong + VUV(displayCode = "VUV", flag = "๐Ÿ‡ป๐Ÿ‡บ"), // Vanuatu Vatu + WST(displayCode = "WST", flag = "๐Ÿ‡ผ๐Ÿ‡ธ"), // Samoan Tala + XAF(displayCode = "XAF", flag = "๐Ÿ‡จ๐Ÿ‡ฒ"), // CFA Franc BEAC + XCD(displayCode = "XCD", flag = "๐Ÿ‡ฑ๐Ÿ‡จ"), // East Caribbean Dollar + XOF(displayCode = "XOF", flag = "๐Ÿ‡จ๐Ÿ‡ฎ"), // CFA Franc BCEAO + XPF(displayCode = "XPF", flag = "๐Ÿ‡ณ๐Ÿ‡จ"), // CFP Franc + YER(displayCode = "YER", flag = "๐Ÿ‡พ๐Ÿ‡ช"), // Yemeni Rial + ZAR(displayCode = "ZAR", flag = "๐Ÿ‡ฟ๐Ÿ‡ฆ"), // South African Rand + ZMW(displayCode = "ZMW", flag = "๐Ÿ‡ฟ๐Ÿ‡ฒ"); // Zambian Kwacha companion object { val values = values().toList() diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/lnurl/Lnurl.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/lnurl/Lnurl.kt index c213a87bf..f4c9dfe2b 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/lnurl/Lnurl.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/lnurl/Lnurl.kt @@ -224,11 +224,11 @@ sealed interface Lnurl { return when (tag) { Tag.Withdraw.label -> { val k1 = json["k1"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() } ?: throw LnurlError.Withdraw.MissingK1 - val minWithdrawable = json["minWithdrawable"]?.jsonPrimitive?.floatOrNull?.takeIf { it > 0f }?.toLong()?.msat + val minWithdrawable = json["minWithdrawable"]?.jsonPrimitive?.doubleOrNull?.takeIf { it > 0f }?.toLong()?.msat ?: json["minWithdrawable"]?.jsonPrimitive?.long?.takeIf { it > 0 }?.msat ?: 0.msat - val maxWithdrawable = json["maxWithdrawable"]?.jsonPrimitive?.floatOrNull?.takeIf { it > 0f }?.toLong()?.msat - ?: json["maxWithdrawable"]?.jsonPrimitive?.long?.coerceAtLeast(minWithdrawable.msat)?.msat + val maxWithdrawable = json["maxWithdrawable"]?.jsonPrimitive?.doubleOrNull?.takeIf { it > 0f }?.toLong()?.msat + ?: json["maxWithdrawable"]?.jsonPrimitive?.long?.takeIf { it > 0 }?.msat ?: minWithdrawable val dDesc = json["defaultDescription"]?.jsonPrimitive?.content ?: "" LnurlWithdraw( @@ -236,15 +236,15 @@ sealed interface Lnurl { callback = callback, k1 = k1, defaultDescription = dDesc, - minWithdrawable = minWithdrawable, + minWithdrawable = minWithdrawable.coerceAtMost(maxWithdrawable), maxWithdrawable = maxWithdrawable ) } Tag.Pay.label -> { - val minSendable = json["minSendable"]?.jsonPrimitive?.floatOrNull?.takeIf { it > 0f }?.toLong()?.msat + val minSendable = json["minSendable"]?.jsonPrimitive?.doubleOrNull?.takeIf { it > 0f }?.toLong()?.msat ?: json["minSendable"]?.jsonPrimitive?.longOrNull?.takeIf { it > 0 }?.msat ?: throw LnurlError.Pay.Intent.InvalidMin - val maxSendable = json["maxSendable"]?.jsonPrimitive?.floatOrNull?.takeIf { it > 0f }?.toLong()?.msat + val maxSendable = json["maxSendable"]?.jsonPrimitive?.doubleOrNull?.takeIf { it > 0f }?.toLong()?.msat ?: json["maxSendable"]?.jsonPrimitive?.longOrNull?.coerceAtLeast(minSendable.msat)?.msat ?: throw LnurlError.Pay.Intent.MissingMax val metadata = LnurlPay.parseMetadata(json["metadata"]?.jsonPrimitive?.content ?: throw LnurlError.Pay.Intent.MissingMetadata) 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 086252bca..110de28b0 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 @@ -655,14 +655,14 @@ data class WalletPaymentOrderRow( } /** - * Implement this function to execute platform specific code when a payment completes. + * Implement this function to execute platform specific code when a payment is saved to the database. * For example, on iOS this is used to enqueue the (encrypted) payment for upload to CloudKit. * * This function is invoked inside the same transaction used to add/modify the row. * This means any database operations performed in this function are atomic, * with respect to the referenced row. */ -expect fun didCompleteWalletPayment(id: WalletPaymentId, database: PaymentsDatabase) +expect fun didSaveWalletPayment(id: WalletPaymentId, database: PaymentsDatabase) /** * Implement this function to execute platform specific code when a payment is deleted. diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/CloudData.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/CloudData.kt index 60f8fdf16..eef266d69 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/CloudData.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/CloudData.kt @@ -65,10 +65,13 @@ data class CloudData( val outgoing: LightningOutgoingPaymentWrapper?, @ByteString @SerialName("so") - val spliceOutgoing: SpliceOutgoingPaymentWrapper?, + val spliceOutgoing: SpliceOutgoingPaymentWrapper? = null, @ByteString @SerialName("cc") - val channelClose: ChannelClosePaymentWrapper?, + val channelClose: ChannelClosePaymentWrapper? = null, + @ByteString + @SerialName("sc") + val spliceCpfp: SpliceCpfpPaymentWrapper? = null, @SerialName("v") val version: Int, @ByteString @@ -80,6 +83,7 @@ data class CloudData( outgoing = null, spliceOutgoing = null, channelClose = null, + spliceCpfp = null, version = CloudDataVersion.V0.value, padding = ByteArray(size = 0) ) @@ -89,6 +93,7 @@ data class CloudData( outgoing = if (outgoing is LightningOutgoingPayment) LightningOutgoingPaymentWrapper(outgoing) else null, spliceOutgoing = if (outgoing is SpliceOutgoingPayment) SpliceOutgoingPaymentWrapper(outgoing) else null, channelClose = if (outgoing is ChannelCloseOutgoingPayment) ChannelClosePaymentWrapper(outgoing) else null, + spliceCpfp = if (outgoing is SpliceCpfpOutgoingPayment) SpliceCpfpPaymentWrapper(outgoing) else null, version = CloudDataVersion.V0.value, padding = ByteArray(size = 0) ) @@ -107,6 +112,7 @@ data class CloudData( outgoing != null -> outgoing.unwrap() spliceOutgoing != null -> spliceOutgoing.unwrap() channelClose != null -> channelClose.unwrap() + spliceCpfp != null -> spliceCpfp.unwrap() else -> null } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/LightningOutgoingType.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/LightningOutgoingType.kt index 3833c6ff9..80e9a5e1e 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/LightningOutgoingType.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/LightningOutgoingType.kt @@ -66,7 +66,7 @@ data class LightningOutgoingPaymentWrapper( LegacyChannelCloseHelper.convertLegacyToChannelClose( id = id, recipientAmount = msat.msat, - partsAmount = closingTxsParts.sumOf { it.sat }.sat, + partsAmount = closingTxsParts.takeIf { it.isNotEmpty() }?.sumOf { it.sat }?.sat, partsTxId = closingTxsParts.firstOrNull()?.txId?.byteVector32(), detailsBlob = this.details.blob, statusBlob = this.status?.blob, diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/SpliceCpfpPaymentType.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/SpliceCpfpPaymentType.kt new file mode 100644 index 000000000..a297e9091 --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/SpliceCpfpPaymentType.kt @@ -0,0 +1,64 @@ +package fr.acinq.phoenix.db.cloud + +import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.utils.toByteVector32 +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.cbor.ByteString +import kotlinx.serialization.cbor.Cbor +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray + +@Serializable +@OptIn(ExperimentalSerializationApi::class) +data class SpliceCpfpPaymentWrapper( + @Serializable(with = UUIDSerializer::class) + val id: UUID, + val miningFeeSat: Long, + @ByteString + val channelId: ByteArray, + @ByteString + val txId: ByteArray, + val createdAt: Long, + val confirmedAt: Long?, + val lockedAt: Long? +) { + constructor(payment: SpliceCpfpOutgoingPayment) : this( + id = payment.id, + miningFeeSat = payment.miningFees.sat, + channelId = payment.channelId.toByteArray(), + txId = payment.txId.toByteArray(), + createdAt = payment.createdAt, + confirmedAt = payment.confirmedAt, + lockedAt = payment.lockedAt + ) + + @Throws(Exception::class) + fun unwrap() = SpliceCpfpOutgoingPayment( + id = id, + miningFees = miningFeeSat.sat, + channelId = channelId.toByteVector32(), + txId = txId.toByteVector32(), + createdAt = createdAt, + confirmedAt = confirmedAt, + lockedAt = lockedAt, + ) + + companion object +} + +@OptIn(ExperimentalSerializationApi::class) +fun SpliceCpfpOutgoingPayment.cborSerialize(): ByteArray { + val wrapper = SpliceCpfpPaymentWrapper(payment = this) + return Cbor.encodeToByteArray(wrapper) +} + +@OptIn(ExperimentalSerializationApi::class) +@Throws(Exception::class) +fun SpliceCpfpPaymentWrapper.cborDeserialize( + blob: ByteArray +): SpliceCpfpPaymentWrapper { + return cborSerializer().decodeFromByteArray(blob) +} \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/ChannelCloseOutgoingQueries.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/ChannelCloseOutgoingQueries.kt index 1dc18c147..6a3d72798 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/ChannelCloseOutgoingQueries.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/ChannelCloseOutgoingQueries.kt @@ -17,13 +17,12 @@ package fr.acinq.phoenix.db.payments import fr.acinq.lightning.db.ChannelCloseOutgoingPayment -import fr.acinq.lightning.db.SpliceOutgoingPayment import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.sat import fr.acinq.lightning.utils.toByteVector32 import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.db.PaymentsDatabase -import fr.acinq.phoenix.db.didCompleteWalletPayment +import fr.acinq.phoenix.db.didSaveWalletPayment class ChannelCloseOutgoingQueries(val database: PaymentsDatabase) { private val channelCloseQueries = database.channelCloseOutgoingPaymentQueries @@ -34,30 +33,37 @@ class ChannelCloseOutgoingQueries(val database: PaymentsDatabase) { fun addChannelCloseOutgoingPayment(payment: ChannelCloseOutgoingPayment) { val (closingInfoType, closingInfoBlob) = payment.mapClosingTypeToDb() - channelCloseQueries.insertChannelCloseOutgoing( - id = payment.id.toString(), - recipient_amount_sat = payment.recipientAmount.sat, - address = payment.address, - is_default_address = if (payment.isSentToDefaultAddress) 1 else 0, - mining_fees_sat = payment.miningFees.sat, - tx_id = payment.txId.toByteArray(), - created_at = payment.createdAt, - confirmed_at = payment.confirmedAt, - locked_at = payment.lockedAt, - channel_id = payment.channelId.toByteArray(), - closing_info_type = closingInfoType, - closing_info_blob = closingInfoBlob, - ) + database.transaction { + channelCloseQueries.insertChannelCloseOutgoing( + id = payment.id.toString(), + recipient_amount_sat = payment.recipientAmount.sat, + address = payment.address, + is_default_address = if (payment.isSentToDefaultAddress) 1 else 0, + mining_fees_sat = payment.miningFees.sat, + tx_id = payment.txId.toByteArray(), + created_at = payment.createdAt, + confirmed_at = payment.confirmedAt, + locked_at = payment.lockedAt, + channel_id = payment.channelId.toByteArray(), + closing_info_type = closingInfoType, + closing_info_blob = closingInfoBlob, + ) + didSaveWalletPayment(WalletPaymentId.ChannelCloseOutgoingPaymentId(payment.id), database) + } } fun setConfirmed(id: UUID, confirmedAt: Long) { - channelCloseQueries.setConfirmed(confirmed_at = confirmedAt, id = id.toString()) - didCompleteWalletPayment(WalletPaymentId.ChannelCloseOutgoingPaymentId(id), database) + database.transaction { + channelCloseQueries.setConfirmed(confirmed_at = confirmedAt, id = id.toString()) + didSaveWalletPayment(WalletPaymentId.ChannelCloseOutgoingPaymentId(id), database) + } } fun setLocked(id: UUID, lockedAt: Long) { - channelCloseQueries.setLocked(locked_at = lockedAt, id = id.toString()) - didCompleteWalletPayment(WalletPaymentId.ChannelCloseOutgoingPaymentId(id), database) + database.transaction { + channelCloseQueries.setLocked(locked_at = lockedAt, id = id.toString()) + didSaveWalletPayment(WalletPaymentId.ChannelCloseOutgoingPaymentId(id), database) + } } companion object { diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/IncomingQueries.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/IncomingQueries.kt index f7a24425a..1fedcf932 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/IncomingQueries.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/IncomingQueries.kt @@ -66,54 +66,60 @@ class IncomingQueries(private val database: PaymentsDatabase) { received_with_blob = receivedWithBlob, payment_hash = paymentHash.toByteArray() ) - didCompleteWalletPayment(WalletPaymentId.IncomingPaymentId(paymentHash), database) + didSaveWalletPayment(WalletPaymentId.IncomingPaymentId(paymentHash), database) } } fun setLocked(paymentHash: ByteVector32, lockedAt: Long) { - val paymentInDb = queries.get( - payment_hash = paymentHash.toByteArray(), - mapper = ::mapIncomingPayment - ).executeAsOneOrNull() - val newReceivedWith = paymentInDb?.received?.receivedWith?.map { - when (it) { - is IncomingPayment.ReceivedWith.NewChannel -> it.copy(lockedAt = lockedAt) - is IncomingPayment.ReceivedWith.SpliceIn -> it.copy(lockedAt = lockedAt) - else -> it + database.transaction { + val paymentInDb = queries.get( + payment_hash = paymentHash.toByteArray(), + mapper = ::mapIncomingPayment + ).executeAsOneOrNull() + val newReceivedWith = paymentInDb?.received?.receivedWith?.map { + when (it) { + is IncomingPayment.ReceivedWith.NewChannel -> it.copy(lockedAt = lockedAt) + is IncomingPayment.ReceivedWith.SpliceIn -> it.copy(lockedAt = lockedAt) + else -> it + } } + val (newReceivedWithType, newReceivedWithBlob) = newReceivedWith?.mapToDb() + ?: (null to null) + queries.updateReceived( + // we override the previous received_at timestamp to trigger a refresh of the payment's cache data + // because the list-all query feeding the cache uses `received_at` for incoming payments + received_at = lockedAt, + received_with_type = newReceivedWithType, + received_with_blob = newReceivedWithBlob, + payment_hash = paymentHash.toByteArray() + ) + didSaveWalletPayment(WalletPaymentId.IncomingPaymentId(paymentHash), database) } - val (newReceivedWithType, newReceivedWithBlob) = newReceivedWith?.mapToDb() ?: (null to null) - queries.updateReceived( - // we override the previous received_at timestamp to trigger a refresh of the payment's cache data - // because the list-all query feeding the cache uses `received_at` for incoming payments - received_at = lockedAt, - received_with_type = newReceivedWithType, - received_with_blob = newReceivedWithBlob, - payment_hash = paymentHash.toByteArray() - ) - didCompleteWalletPayment(WalletPaymentId.IncomingPaymentId(paymentHash), database) } fun setConfirmed(paymentHash: ByteVector32, confirmedAt: Long) { - val paymentInDb = queries.get( - payment_hash = paymentHash.toByteArray(), - mapper = ::mapIncomingPayment - ).executeAsOneOrNull() - val newReceivedWith = paymentInDb?.received?.receivedWith?.map { - when (it) { - is IncomingPayment.ReceivedWith.NewChannel -> it.copy(confirmedAt = confirmedAt) - is IncomingPayment.ReceivedWith.SpliceIn -> it.copy(confirmedAt = confirmedAt) - else -> it + database.transaction { + val paymentInDb = queries.get( + payment_hash = paymentHash.toByteArray(), + mapper = ::mapIncomingPayment + ).executeAsOneOrNull() + val newReceivedWith = paymentInDb?.received?.receivedWith?.map { + when (it) { + is IncomingPayment.ReceivedWith.NewChannel -> it.copy(confirmedAt = confirmedAt) + is IncomingPayment.ReceivedWith.SpliceIn -> it.copy(confirmedAt = confirmedAt) + else -> it + } } + val (newReceivedWithType, newReceivedWithBlob) = newReceivedWith?.mapToDb() + ?: (null to null) + queries.updateReceived( + received_at = paymentInDb?.received?.receivedAt, + received_with_type = newReceivedWithType, + received_with_blob = newReceivedWithBlob, + payment_hash = paymentHash.toByteArray() + ) + didSaveWalletPayment(WalletPaymentId.IncomingPaymentId(paymentHash), database) } - val (newReceivedWithType, newReceivedWithBlob) = newReceivedWith?.mapToDb() ?: (null to null) - queries.updateReceived( - received_at = paymentInDb?.received?.receivedAt, - received_with_type = newReceivedWithType, - received_with_blob = newReceivedWithBlob, - payment_hash = paymentHash.toByteArray() - ) - didCompleteWalletPayment(WalletPaymentId.IncomingPaymentId(paymentHash), database) } fun getIncomingPayment(paymentHash: ByteVector32): IncomingPayment? { diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/IncomingReceivedWithType.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/IncomingReceivedWithType.kt index bb1023bde..fd0a73d5f 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/IncomingReceivedWithType.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/IncomingReceivedWithType.kt @@ -194,7 +194,15 @@ sealed class IncomingReceivedWithData { IncomingReceivedWithTypeVersion.MULTIPARTS_V1 -> DbTypesHelper.polymorphicFormat.decodeFromString(SetSerializer(PolymorphicSerializer(Part::class)), json).map { when (it) { is Part.Htlc.V0 -> IncomingPayment.ReceivedWith.LightningPayment(it.amount, it.channelId, it.htlcId) - is Part.NewChannel.V0 -> null // does not apply, MULTIPARTS_V1 only use new-channel parts >= V1 + is Part.NewChannel.V0 -> IncomingPayment.ReceivedWith.NewChannel( + amount = it.amount, + serviceFee = it.fees, + miningFee = 0.sat, + channelId = it.channelId ?: ByteVector32.Zeroes, + txId = ByteVector32.Zeroes, + confirmedAt = 0, + lockedAt = 0, + ) is Part.NewChannel.V1 -> IncomingPayment.ReceivedWith.NewChannel( amount = it.amount, serviceFee = it.fees, 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 51f64c2e2..7c26a77d5 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 @@ -64,6 +64,7 @@ sealed class OutgoingDetailsData { @Deprecated("channel close are now stored in their own table") sealed class Closing : OutgoingDetailsData() { @Serializable + @Suppress("DEPRECATION") data class V0( @Serializable val channelId: ByteVector32, val closingAddress: String, @@ -73,6 +74,7 @@ sealed class OutgoingDetailsData { companion object { /** Deserialize the details of an outgoing payment. Return null if the details is for a legacy channel closing payment (see [deserializeLegacyClosingDetails]). */ + @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)) } @@ -83,6 +85,7 @@ sealed class OutgoingDetailsData { } /** Returns the channel closing details from a blob, for backward-compatibility purposes. */ + @Suppress("DEPRECATION") fun deserializeLegacyClosingDetails(blob: ByteArray): Closing.V0 = DbTypesHelper.decodeBlob(blob) { json, format -> format.decodeFromString(json) } 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 b006de80b..ee82bd384 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 @@ -31,7 +31,7 @@ import fr.acinq.lightning.utils.* import fr.acinq.lightning.wire.FailureMessage import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.db.PaymentsDatabase -import fr.acinq.phoenix.db.didCompleteWalletPayment +import fr.acinq.phoenix.db.didSaveWalletPayment import fr.acinq.phoenix.utils.migrations.LegacyChannelCloseHelper import fr.acinq.secp256k1.Hex @@ -96,7 +96,7 @@ class OutgoingQueries(val database: PaymentsDatabase) { if (queries.changes().executeAsOne() != 1L) { result = false } else { - didCompleteWalletPayment(WalletPaymentId.LightningOutgoingPaymentId(id), database) + didSaveWalletPayment(WalletPaymentId.LightningOutgoingPaymentId(id), database) } } return result diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/SpliceCpfpOutgoingQueries.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/SpliceCpfpOutgoingQueries.kt index eb1ef2686..e7b8baed0 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/SpliceCpfpOutgoingQueries.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/SpliceCpfpOutgoingQueries.kt @@ -23,21 +23,24 @@ import fr.acinq.lightning.utils.sat import fr.acinq.lightning.utils.toByteVector32 import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.db.PaymentsDatabase -import fr.acinq.phoenix.db.didCompleteWalletPayment +import fr.acinq.phoenix.db.didSaveWalletPayment class SpliceCpfpOutgoingQueries(val database: PaymentsDatabase) { private val cpfpQueries = database.spliceCpfpOutgoingPaymentsQueries fun addCpfpPayment(payment: SpliceCpfpOutgoingPayment) { - cpfpQueries.insertCpfp( - id = payment.id.toString(), - mining_fees_sat = payment.miningFees.sat, - channel_id = payment.channelId.toByteArray(), - tx_id = payment.txId.toByteArray(), - created_at = payment.createdAt, - confirmed_at = payment.confirmedAt, - locked_at = payment.lockedAt - ) + database.transaction { + cpfpQueries.insertCpfp( + id = payment.id.toString(), + mining_fees_sat = payment.miningFees.sat, + channel_id = payment.channelId.toByteArray(), + tx_id = payment.txId.toByteArray(), + created_at = payment.createdAt, + confirmed_at = payment.confirmedAt, + locked_at = payment.lockedAt + ) + didSaveWalletPayment(WalletPaymentId.SpliceCpfpOutgoingPaymentId(payment.id), database) + } } fun getCpfp(id: UUID): SpliceCpfpOutgoingPayment? { @@ -48,13 +51,17 @@ class SpliceCpfpOutgoingQueries(val database: PaymentsDatabase) { } fun setConfirmed(id: UUID, confirmedAt: Long) { - cpfpQueries.setConfirmed(confirmed_at = confirmedAt, id = id.toString()) - didCompleteWalletPayment(WalletPaymentId.SpliceCpfpOutgoingPaymentId(id), database) + database.transaction { + cpfpQueries.setConfirmed(confirmed_at = confirmedAt, id = id.toString()) + didSaveWalletPayment(WalletPaymentId.SpliceCpfpOutgoingPaymentId(id), database) + } } fun setLocked(id: UUID, lockedAt: Long) { - cpfpQueries.setLocked(locked_at = lockedAt, id = id.toString()) - didCompleteWalletPayment(WalletPaymentId.SpliceCpfpOutgoingPaymentId(id), database) + database.transaction { + cpfpQueries.setLocked(locked_at = lockedAt, id = id.toString()) + didSaveWalletPayment(WalletPaymentId.SpliceCpfpOutgoingPaymentId(id), database) + } } private companion object { diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/SpliceOutgoingQueries.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/SpliceOutgoingQueries.kt index cd7ecb32d..2156aa34c 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/SpliceOutgoingQueries.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/SpliceOutgoingQueries.kt @@ -22,23 +22,26 @@ import fr.acinq.lightning.utils.sat import fr.acinq.lightning.utils.toByteVector32 import fr.acinq.phoenix.data.WalletPaymentId import fr.acinq.phoenix.db.PaymentsDatabase -import fr.acinq.phoenix.db.didCompleteWalletPayment +import fr.acinq.phoenix.db.didSaveWalletPayment class SpliceOutgoingQueries(val database: PaymentsDatabase) { private val spliceOutQueries = database.spliceOutgoingPaymentsQueries fun addSpliceOutgoingPayment(payment: SpliceOutgoingPayment) { - spliceOutQueries.insertSpliceOutgoing( - id = payment.id.toString(), - recipient_amount_sat = payment.recipientAmount.sat, - address = payment.address, - mining_fees_sat = payment.miningFees.sat, - channel_id = payment.channelId.toByteArray(), - tx_id = payment.txId.toByteArray(), - created_at = payment.createdAt, - confirmed_at = payment.confirmedAt, - locked_at = payment.lockedAt - ) + database.transaction { + spliceOutQueries.insertSpliceOutgoing( + id = payment.id.toString(), + recipient_amount_sat = payment.recipientAmount.sat, + address = payment.address, + mining_fees_sat = payment.miningFees.sat, + channel_id = payment.channelId.toByteArray(), + tx_id = payment.txId.toByteArray(), + created_at = payment.createdAt, + confirmed_at = payment.confirmedAt, + locked_at = payment.lockedAt + ) + didSaveWalletPayment(WalletPaymentId.SpliceOutgoingPaymentId(payment.id), database) + } } fun getSpliceOutPayment(id: UUID): SpliceOutgoingPayment? { @@ -49,13 +52,17 @@ class SpliceOutgoingQueries(val database: PaymentsDatabase) { } fun setConfirmed(id: UUID, confirmedAt: Long) { - spliceOutQueries.setConfirmed(confirmed_at = confirmedAt, id = id.toString()) - didCompleteWalletPayment(WalletPaymentId.SpliceOutgoingPaymentId(id), database) + database.transaction { + spliceOutQueries.setConfirmed(confirmed_at = confirmedAt, id = id.toString()) + didSaveWalletPayment(WalletPaymentId.SpliceOutgoingPaymentId(id), database) + } } fun setLocked(id: UUID, lockedAt: Long) { - spliceOutQueries.setLocked(locked_at = lockedAt, id = id.toString()) - didCompleteWalletPayment(WalletPaymentId.SpliceOutgoingPaymentId(id), database) + database.transaction { + spliceOutQueries.setLocked(locked_at = lockedAt, id = id.toString()) + didSaveWalletPayment(WalletPaymentId.SpliceOutgoingPaymentId(id), database) + } } companion object { 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 a9b275ca9..7fd24f6c3 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 @@ -39,7 +39,11 @@ class BalanceManager( private val _balance = MutableStateFlow(null) val balance: StateFlow = _balance - /** The swap-in wallet balance, grouped by its utxos' confirmation status. Reserved utxos are filtered out. */ + /** The swap-in wallet. Reserved utxos are filtered out. */ + private val _swapInWallet = MutableStateFlow(null) + val swapInWallet: StateFlow = _swapInWallet + + /** The swap-in wallet balance. Reserved utxos are filtered out. */ private val _swapInWalletBalance = MutableStateFlow(WalletBalance.empty()) val swapInWalletBalance: StateFlow = _swapInWalletBalance @@ -91,20 +95,20 @@ class BalanceManager( }.toMap().filter { it.value.isNotEmpty() }, parentTxs = swapInWallet.parentTxs, ) - val withConfirmations = walletWithoutReserved.withConfirmations( + walletWithoutReserved.withConfirmations( currentBlockHeight = currentBlockHeight, minConfirmations = swapInConfirmations ) - WalletBalance( - deeplyConfirmed = withConfirmations.deeplyConfirmed.balance, - weaklyConfirmed = withConfirmations.weaklyConfirmed.balance, - weaklyConfirmedMinBlockNeeded = withConfirmations.weaklyConfirmed.minOfOrNull { - withConfirmations.confirmationsNeeded(it) + }.collect { wallet -> + _swapInWallet.value = wallet + _swapInWalletBalance.value = WalletBalance( + deeplyConfirmed = wallet.deeplyConfirmed.balance, + weaklyConfirmed = wallet.weaklyConfirmed.balance, + weaklyConfirmedMinBlockNeeded = wallet.weaklyConfirmed.minOfOrNull { + wallet.confirmationsNeeded(it) }, - unconfirmed = withConfirmations.unconfirmed.balance, + unconfirmed = wallet.unconfirmed.balance, ) - }.collect { balance -> - _swapInWalletBalance.value = balance } } } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/migrations/LegacyChannelCloseHelper.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/migrations/LegacyChannelCloseHelper.kt index a96b798ce..206fdbffc 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/migrations/LegacyChannelCloseHelper.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/migrations/LegacyChannelCloseHelper.kt @@ -74,7 +74,7 @@ object LegacyChannelCloseHelper { } return ChannelCloseOutgoingPayment( id = id, - recipientAmount = recipientAmount.truncateToSatoshi(), + recipientAmount = recipientAmount.truncateToSatoshi() - fees, address = closingDetails?.closingAddress ?: "", isSentToDefaultAddress = closingDetails?.isSentToDefaultAddress ?: (closingType == ChannelClosingType.Local diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/data/lnurl/LnurlPayTest.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/data/lnurl/LnurlPayTest.kt index 2938ef186..902489668 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/data/lnurl/LnurlPayTest.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/data/lnurl/LnurlPayTest.kt @@ -1,5 +1,6 @@ package fr.acinq.phoenix.data.lnurl +import fr.acinq.lightning.utils.msat import io.ktor.client.* import io.ktor.client.engine.mock.* import io.ktor.client.plugins.contentnegotiation.* @@ -13,7 +14,6 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFailsWith import kotlin.test.assertIs class LnurlPayTest { @@ -22,9 +22,9 @@ class LnurlPayTest { { "callback":"https://lnurl.fiatjaf.com/lnurl-pay/callback/0abd4cdb7b0b62e50d5a3e8704287049b3a6dff50f40ff5db821955cca00791b", "tag":"payRequest", - "maxSendable":20000, - "minSendable":5000, - "metadata":"[[\"text/plain\",\"rtk\"],[\"text/long-desc\",\"SKM OZ\"],[\"image/png;base64\",\"iVBORw0KGgoAAAANSUhEUgAAASwAAAEsCAYAAAB5fY51AAATOElEQVR4nO3dz4slVxXA8fIHiEhCjBrcCHEEXbiLkiwd/LFxChmQWUVlpqfrdmcxweAk9r09cUrQlWQpbgXBv8CdwrhRJqn7umfEaEgQGVGzUEwkIu6ei6TGmvH16/ej6p5z7v1+4Ozfq3vqO5dMZ7qqAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgHe4WbjuutBKfw4AWMrNwnUXw9zFMCdaANS6J1ZEC4BWC2NFtABoszRWRAuAFivFimgBkLZWrIgWACkbxYpoAUhtq1gRLQCpjBIrogVU1ZM32webma9dDM+7LrR3J4bnm5mvn7zZPij9GS0bNVZEaxTsvDEu+iea6F9w0d9a5QVpunDcRP/C7uzgM9Kf3ZJJYkW0NsLOG7PzynMPNDFcaTr/2+1eFH/kon/q67evfkD6O2k2aayI1krYeYPO3mjf67rwjIv+zZFfmL+5zu+18/bd0t9RmySxIlonYueNuvTS4cfe/tNhuhem6cKvXGw/LP1dtUgaK6L1f9h5o/aODj/rov9Hihemif4vzS3/SenvLE0kVkTrLnbeKBfDYxNch0+bv7p47RPS312KaKyIFjtv1U53cMZ1/u8yL42/s3/76iPSzyA1FbEqOFrsvFGXX24fdtH/UfKFaaKP0s8hJVWxKjBa7LxhTfQ3xF+WGOYu+h9LP4sUVMaqsGix80a56J+WP7T/ze7s4PPSz2RKqmNVSLTYeaMuHfmPuBjekj6w4TTRvyb9XKZiIlaZR4udN6yJ/gfSh7Vo9mb+kvSzGZupWGUcLXbeqJ1XnnvAdf7f0gd1wrwq/XzGZDJWGUaLnTesmYWLCg5p2Twm/YzGYDpWmUWLnTfMxfAzBQd04ux24XvSz2hbWcQqo2ix80ZdmF94j4v+P9IHtHz8TenntI2sYtWP4Wix84Zd7g4flz+c00f6OW0qy1j1YzRa7LxhTRd2pA9mlWluffvT0s9qXVnHqh+D0WLnDbPyUjWd/4r0s1qHlec6yhiLlpWzsbbzSTTRf1f6YFaZvdmhk35Wq7LyQow6hqLFzhvWRP8d6YNZZZoYvPSzWkWRserHSLTYecPcLDwrfTArzrekn9Vpio5VPwaixc4b1sTDfQUHs8rsSj+rZYjVYJRHi503bLfzX1ZwMKdO0x18UfpZnYRYLRjF0WLnDds/PnhU+mBWmYsvPftR6We1CLFaMkqjxc4b5zr/uvThLF98/wfpZ7QIsVrl7HRGi503zHXhJ+IHtGSaGH4k/YzuR6zWefn0RYudN8xFf176gJbN3lH4gvQzGiJWG4yyaLHzxrku/FP6kE5Y9D9JP5shYrXVWbbS5zfEzhvmutCKH9TC8U9LP5sesRrlZWylz7HHzht28bh9SOCXSJ623Gr+pCFWo55rK32eVcXOm7c3O3TiB3bP+PPSz6SqiNVEL2Yrfa5Vxc6b57rwC/lDC/Mm+p9KP4uqIlaTjpJosfOGvfNbcO+IHlwXji/8+pn3Sz8LYpVgFESLnTdupzs408Twhszh+Tv7t68+Iv0MiFXCURAtdt64y93h4030/0p8eH/e6Q7OSH93YiUwCqJV8s5nwUX/RLq/RfF3dm9f+7j4dyZWcqMgWiXufFb2jw8ebWL43ZQH13T+50/95uCD0t+VWCkYBdEqaeezdOW1K+9rYvAuhrfGXU7/ejMLF6t59S7p70isFI2CaJWw89m7/HL7sJv5b7oYXt3u4PzNvVn4mvT36RErhaMgWlWV784Xpznyn2ti+KGL/verHFjThRdd57+/0137lPRnHyJWikdJtHq57HzxvvGi/1DTHX7VzcJ114X27sx82O3Cl7T+fAmxMjDKotWzuvMwilgZGqXRApIgVgaHaKFExMrwEC2UhFhlMEQLJSBWGQ3RQs6IVYZDtJAjYpXxEC3khFgVMEQLOSBWBQ3RgmXEqsAhWrDIdaGt63rOlDdEC6b0v2dO+sVhhILFTQtWDH8ppvSLwwgGi2hBu/t/g6/0i8MIB4toQatFv25c+sVhFASLaEGbRbEiWOUOf3sItU6KFcEqd/iRB6i0LFYEq9zh57SgzmmxIljlDj9cClVWiRXBKnf4iXiosWqsCFa5w//GAxXWiRXBKnfW2RGihUmsGyuCVe6suydEC6PaJFYEq9zZZFeIFkaxaawIVrmz6b4QLWxlm1gRrHJnm50hWtjItrEiWOXOtntDtLCWMWJFsMqdMXaHaGElY8WKYJU7Y+0P0cJSY8aKYJU7Y+4Q0cJCY8eKYJU7Y+8R0cI9pogVwSp3ptglooWqqqaLFcEqd6baJ6JVuCljRbDKnSl3imgVaupYEaxyZ+q9IlqFSRGrhME6K/Uc67q29Mtif1nX9dksgkW0ypEqVgmDdUPiOZ4/f/6huq7fUBCilULVf+5sgkW08pcyVgmDNa8Fblm1/tvVPaEafO58gkW08pU6VomDlfSWpfx2tTBUveyCRbTyIxGrxMGaL3tJx1brvF0tDdXgs+cXLKKVD6lYCQQryS1L4e1qpVD1sg0W0bJPMlYCwZqv8+JuqtZzu1orVIPPn2+wiJZd0rESCtaktywlt6uNQtXLPlhEyx4NsRIK1nybl/k0teztaqtQDb5D/sEiWnZoiZVgsCa5ZQnerkYJVa+YYBEt/TTFSjBY8zFf8F6d/nY1aqgG36OcYBEtvbTFSjhYo96yEt+uJglVr7hgES19NMZKOFjzMV/6Os3tatJQDb5LecEiWnpojZWCYI1yy0pwu0oSql6xwSJa8jTHSkGw5mOEoJ7udpU0VIPvU26wiJYc7bFSEqytblkT3a5EQtUrPlhEKz0LsVISrPk2cainuV29Udf19fPnzz804kqs850IFtFKx0qsFAVro1tWgv92JRIugkW0krEUK0XBmteb/T93qX7uKmm4CBbRSsJarJQFa61bltBPtScJF8EiWpOzGCtlwZrX6/0TLJL/z+Ck4SJYRGtSVmOlMFgr3bKU/IsMk4WLYBGtyViOlcJgzevV/kVOLf/e1SThIlhEaxLWY6U0WEtvWYpuV5OFi2ARrdHlECulwZrXy39Bg7bb1ejhIlhEa1S5xEpxsBbespTfrkYLF8EiWqPJKVaKgzWvF/++Pgu3q63DRbCI1ihyi5XyYN1zyzJ4u9o4XASLaG0tx1gpD9a8vvfXt1u9Xa0dLoJFtLaSa6wMBOtGVWVzu1o5XASLaG0s51gZCNa8ruuzdV63q1PDRbCI1kZyj5WRYN2o87xdnRgugkW01lZCrIwEiyFYRGuZUmJFsMod6b0jWiMpKVYEq9yR3juiNYLSYkWwyh3pvSNaWyoxVgSr3JHeO6K1hVJjRbDKHem9I1pbIFhMaSO9dwRrS6VGS/rFYQgWsdpQidGSfnEYgkWstlBatKRfHIZgEastlRQt6ReHIVjEagSlREv6xWEIFrEaSQnRSvSCtOfOnXtT+iVNMe98z19Kf47ig1VarHq5RyvFy1FVd/9NqxLC1dZv/5M40p+j3GCVGqteztFKFaxezuE6d+7cm4N/00r1LUt674jVxHKNVupg9TINV9t/v1r5LUt674hVAjlGSypYvVzCNbxd9WrFtyzpvSNWieQWLelg9TIIV3v/d6oV37Kk945YJZRTtLQEq2cxXItuV71a6S1Leu+IVWK5REtbsHrGwtWe9D1qpbcs6b0jVgJyiJbWYPW0h2vZ7apXK7xlSe8dsRJiPVrag9VTHK72tM9eK7xlSe8dsRJkOVpWgtXTFK5Vble9WtktS3rviJUwq9GyFqyeknC1q37eWtktS3rviJUCFqNlNVg9qXCtc7vq1YpuWdJ7R6yUsBYt68HqCYSrXfcz1opuWdJ7R6wUsRStXILVSxGuTW5XvVrJLUt674iVMlailVuwehOHq930c9VKblnSe0esFLIQrVyDVVV343BjzO+yze1q8LnEb1nSe0eslNIerRyDNUWoBtOO9PkIFrHSSXO0cgrWxKEa5XY1+KyityzpvSNWymmNVg7BmjpUg2lH/swEi1jppTFaloOVMFSj3q4Gn1/sliW9d8TKCG3RshislKEaTDvR9yBYxEo3TdGyFCyhUE1yuxp8J5FblvTeEStjtETLQrCkQjWYdoQjX/bdygwWsbJFQ7Q0B0tBqCa9XQ2+Z/JblvTeESujpKOlMVgaQjWYdoJjX/R9ywkWsbJNMlqagqUsVEluV4PvnvSWRaywFaloaQiWtlANpk1w9MNnkHewiFVeJKIlGSzFoUp6uxo8j2S3LGKFUaSOlkSwNIdqMG3qs68T3rKIFUaTMlopg2UkVCK3q8EzSnLLIlYYVapoJYqAiVANppU69zrRLYtYYXQpoqUgDozAECtMYupoSb84TIbBIlZlmzJa0i8Ok1mwiBWqarpoSb84TEbBIlYYmiJa0i8Ok0mwiBUWGTta0i8Ok0GwiBWWGTNa0i8OYzxYxAqrGCta0i8OYzhYxArrGCNa0i8OYzRYxAqb2DZa0i8OYzBYxArb2CZa0i8OYyxYxApj2DRa0i8OYyhYxApj2iRa0i8OYyRYxApTWDda0i8OYyBYxApTWida0i8OozxYxAoprBot6ReHURwsYoWUVomW9IvDKA0WsYKE06Il/eIwCoNFrCBpWbSkXxxGWbCIFTQ4KVrSLw6jKFjECposipb0i8MoCRaxgkb3R0v6xWEUBItYQbNhtKRfHEY4WMQKFvTRkn5xGMFgEStY4rrQSr84jFCwiBUsSvUbphlFQ6xgGdEqaIgVckC0ChhihZwQrYyHWCFHRCvDIVbIGdHKaIgVSkC0MhhihZIQLcNDrFAiomVwiBVKRrQMDbHCmJ682T7YzHztYnjedaG9OzE838x8/eTN9kHpz7gI0TIwSmNldeeL5aJ/oon+BRf9rVUWr+nCcRP9C7uzg89If/YhoqV4lMUql50vxs4rzz3QxHCl6fxvt1tEf+Sif+rrt69+QPo7VRXRUjlKYpXrzmft7I32va4Lz7jo3xx5Mf/mOr/Xztt3S39HoqVoFMSqhJ3P0qWXDj/29p8O0y1o04Vfudh+WPq7Ei0FoyBWJe18VvaODj/rov9HikVtov9Lc8t/Uvo7Ey3BURCrEnc+Cy6Gxya4Dp82f3Xx2ifEvzvRSj8KYlXyzpu20x2ccZ3/u8zy+jv7t68+Iv0MiFbCURArdt6oyy+3D7vo/yi5wE30Ufo5VBXRSjIKYsXOG9ZEf0N8iWOYu+h/LP0sqopoTToKYlVV7LxZLvqn5Q/tf7M7O/i89DOpKqI1ySiJFTtv1KUj/xEXw1vSBzacJvrXpJ9Lj2iNOEpixc4b1kT/A+nDWjR7M39J+tn0iNYIoyRWVcXOm7XzynMPuM7/W/qgTphXpZ/PENHaYhTFip03rJmFiwoOadk8Jv2MhojWBqMoVlXFzpvmYviZggM6cXa78D3pZ3Q/orXGKItVVbHzZl2YX3iPi/4/0ge0fPxN6ee0CNFaYRTGip037HJ3+Lj84Zw+0s/pJERrySiMVVWx86Y1XdiRPphVprn17U9LP6uTEK0FozRWVcXOm+Zm4br0wax0eJ3/ivSzWoZoDUZxrKqKnTetif670gezyuzNDp30szoN0QrqY1VV7LxpTfTfkT6YVaaJwUs/q1UUHS0Dsaoqdt40NwvPSh/MivMt6We1qiKjZSRWVcXOm9bEw30FB7PK7Eo/q3UUFS1Dsaoqdt603c5/WcHBnDpNd/BF6We1riKiZSxWVcXOm7Z/fPCo9MGsMhdfevaj0s9qE1lHy2CsqoqdN891/nXpw1n+Yvg/SD+jbWQZLaOx6rHzhrku/ET8gJZME8OPpJ/RtrKKlvFYVRU7b5qL/rz0AS2bvaPwBelnNIYsopVBrKqKnTfPdeGf0od0wgvyJ+lnMybT0cokVj123jC9L5J/WvrZjE3vsy4nVlWl+Rzy2/nRXTxuHxL4JZKnvSTZ/kmj92UpI1ZVxc6btzc7dOIHds/489LPZEomopVprHrsvHGuC7+QP7Qwb6L/qfSzSEF1tDKPVY+dN+yd34J7R/TgunB84dfPvF/6WaSiMlqFxKqq2HnzdrqDM00Mb8gcnr+zf/vqI9LPIDVV0SooVj123rjL3eHjTfT/Snx4f97pDs5If3cpKqJVYKx67LxxLvon0v0tir+ze/vax6W/szTRaBUcqx47b9z+8cGjTQy/m/Lgms7//KnfHHxQ+rtqIRItYnUXO2/cldeuvK+JwbsY3hr3JfGvN7NwsZpX75L+jtokjRax+j/sfAYuv9w+7Gb+my6GV7c7OH9zbxa+Jv19tEsSLWK1FDufiebIf66J4Ycu+t+vcmBNF150nf/+TnftU9Kf3ZJJo0Ws1sLOZ+IbL/oPNd3hV90sXHddaO/OzIfdLnyJny/ZziTRIlZbYeeBJUaNFrECMLVRokWsAKSyVbSIFYDUNooWsQIgZa1oESsA0laKFrECoMXSaBErANosjBaxAqDVPdEiVgC063/aWvpzAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQI//AplAdntdLBX1AAAAAElFTkSuQmCC\"]]", + "maxSendable":8156723209123, + "minSendable":1, + "metadata":"[[\"text/plain\",\"'รง!@#$%^&*()_+{}\"],[\"text/long-desc\",\"SKM OZ\"],[\"image/png;base64\",\"iVBORw0KGgoAAAANSUhEUgAAASwAAAEsCAYAAAB5fY51AAATOElEQVR4nO3dz4slVxXA8fIHiEhCjBrcCHEEXbiLkiwd/LFxChmQWUVlpqfrdmcxweAk9r09cUrQlWQpbgXBv8CdwrhRJqn7umfEaEgQGVGzUEwkIu6ei6TGmvH16/ej6p5z7v1+4Ozfq3vqO5dMZ7qqAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgHe4WbjuutBKfw4AWMrNwnUXw9zFMCdaANS6J1ZEC4BWC2NFtABoszRWRAuAFivFimgBkLZWrIgWACkbxYpoAUhtq1gRLQCpjBIrogVU1ZM32webma9dDM+7LrR3J4bnm5mvn7zZPij9GS0bNVZEaxTsvDEu+iea6F9w0d9a5QVpunDcRP/C7uzgM9Kf3ZJJYkW0NsLOG7PzynMPNDFcaTr/2+1eFH/kon/q67evfkD6O2k2aayI1krYeYPO3mjf67rwjIv+zZFfmL+5zu+18/bd0t9RmySxIlonYueNuvTS4cfe/tNhuhem6cKvXGw/LP1dtUgaK6L1f9h5o/aODj/rov9Hihemif4vzS3/SenvLE0kVkTrLnbeKBfDYxNch0+bv7p47RPS312KaKyIFjtv1U53cMZ1/u8yL42/s3/76iPSzyA1FbEqOFrsvFGXX24fdtH/UfKFaaKP0s8hJVWxKjBa7LxhTfQ3xF+WGOYu+h9LP4sUVMaqsGix80a56J+WP7T/ze7s4PPSz2RKqmNVSLTYeaMuHfmPuBjekj6w4TTRvyb9XKZiIlaZR4udN6yJ/gfSh7Vo9mb+kvSzGZupWGUcLXbeqJ1XnnvAdf7f0gd1wrwq/XzGZDJWGUaLnTesmYWLCg5p2Twm/YzGYDpWmUWLnTfMxfAzBQd04ux24XvSz2hbWcQqo2ix80ZdmF94j4v+P9IHtHz8TenntI2sYtWP4Wix84Zd7g4flz+c00f6OW0qy1j1YzRa7LxhTRd2pA9mlWluffvT0s9qXVnHqh+D0WLnDbPyUjWd/4r0s1qHlec6yhiLlpWzsbbzSTTRf1f6YFaZvdmhk35Wq7LyQow6hqLFzhvWRP8d6YNZZZoYvPSzWkWRserHSLTYecPcLDwrfTArzrekn9Vpio5VPwaixc4b1sTDfQUHs8rsSj+rZYjVYJRHi503bLfzX1ZwMKdO0x18UfpZnYRYLRjF0WLnDds/PnhU+mBWmYsvPftR6We1CLFaMkqjxc4b5zr/uvThLF98/wfpZ7QIsVrl7HRGi503zHXhJ+IHtGSaGH4k/YzuR6zWefn0RYudN8xFf176gJbN3lH4gvQzGiJWG4yyaLHzxrku/FP6kE5Y9D9JP5shYrXVWbbS5zfEzhvmutCKH9TC8U9LP5sesRrlZWylz7HHzht28bh9SOCXSJ623Gr+pCFWo55rK32eVcXOm7c3O3TiB3bP+PPSz6SqiNVEL2Yrfa5Vxc6b57rwC/lDC/Mm+p9KP4uqIlaTjpJosfOGvfNbcO+IHlwXji/8+pn3Sz8LYpVgFESLnTdupzs408Twhszh+Tv7t68+Iv0MiFXCURAtdt64y93h4030/0p8eH/e6Q7OSH93YiUwCqJV8s5nwUX/RLq/RfF3dm9f+7j4dyZWcqMgWiXufFb2jw8ebWL43ZQH13T+50/95uCD0t+VWCkYBdEqaeezdOW1K+9rYvAuhrfGXU7/ejMLF6t59S7p70isFI2CaJWw89m7/HL7sJv5b7oYXt3u4PzNvVn4mvT36RErhaMgWlWV784Xpznyn2ti+KGL/verHFjThRdd57+/0137lPRnHyJWikdJtHq57HzxvvGi/1DTHX7VzcJ114X27sx82O3Cl7T+fAmxMjDKotWzuvMwilgZGqXRApIgVgaHaKFExMrwEC2UhFhlMEQLJSBWGQ3RQs6IVYZDtJAjYpXxEC3khFgVMEQLOSBWBQ3RgmXEqsAhWrDIdaGt63rOlDdEC6b0v2dO+sVhhILFTQtWDH8ppvSLwwgGi2hBu/t/g6/0i8MIB4toQatFv25c+sVhFASLaEGbRbEiWOUOf3sItU6KFcEqd/iRB6i0LFYEq9zh57SgzmmxIljlDj9cClVWiRXBKnf4iXiosWqsCFa5w//GAxXWiRXBKnfW2RGihUmsGyuCVe6suydEC6PaJFYEq9zZZFeIFkaxaawIVrmz6b4QLWxlm1gRrHJnm50hWtjItrEiWOXOtntDtLCWMWJFsMqdMXaHaGElY8WKYJU7Y+0P0cJSY8aKYJU7Y+4Q0cJCY8eKYJU7Y+8R0cI9pogVwSp3ptglooWqqqaLFcEqd6baJ6JVuCljRbDKnSl3imgVaupYEaxyZ+q9IlqFSRGrhME6K/Uc67q29Mtif1nX9dksgkW0ypEqVgmDdUPiOZ4/f/6huq7fUBCilULVf+5sgkW08pcyVgmDNa8Fblm1/tvVPaEafO58gkW08pU6VomDlfSWpfx2tTBUveyCRbTyIxGrxMGaL3tJx1brvF0tDdXgs+cXLKKVD6lYCQQryS1L4e1qpVD1sg0W0bJPMlYCwZqv8+JuqtZzu1orVIPPn2+wiJZd0rESCtaktywlt6uNQtXLPlhEyx4NsRIK1nybl/k0teztaqtQDb5D/sEiWnZoiZVgsCa5ZQnerkYJVa+YYBEt/TTFSjBY8zFf8F6d/nY1aqgG36OcYBEtvbTFSjhYo96yEt+uJglVr7hgES19NMZKOFjzMV/6Os3tatJQDb5LecEiWnpojZWCYI1yy0pwu0oSql6xwSJa8jTHSkGw5mOEoJ7udpU0VIPvU26wiJYc7bFSEqytblkT3a5EQtUrPlhEKz0LsVISrPk2cainuV29Udf19fPnzz804kqs850IFtFKx0qsFAVro1tWgv92JRIugkW0krEUK0XBmteb/T93qX7uKmm4CBbRSsJarJQFa61bltBPtScJF8EiWpOzGCtlwZrX6/0TLJL/z+Ck4SJYRGtSVmOlMFgr3bKU/IsMk4WLYBGtyViOlcJgzevV/kVOLf/e1SThIlhEaxLWY6U0WEtvWYpuV5OFi2ARrdHlECulwZrXy39Bg7bb1ejhIlhEa1S5xEpxsBbespTfrkYLF8EiWqPJKVaKgzWvF/++Pgu3q63DRbCI1ihyi5XyYN1zyzJ4u9o4XASLaG0tx1gpD9a8vvfXt1u9Xa0dLoJFtLaSa6wMBOtGVWVzu1o5XASLaG0s51gZCNa8ruuzdV63q1PDRbCI1kZyj5WRYN2o87xdnRgugkW01lZCrIwEiyFYRGuZUmJFsMod6b0jWiMpKVYEq9yR3juiNYLSYkWwyh3pvSNaWyoxVgSr3JHeO6K1hVJjRbDKHem9I1pbIFhMaSO9dwRrS6VGS/rFYQgWsdpQidGSfnEYgkWstlBatKRfHIZgEastlRQt6ReHIVjEagSlREv6xWEIFrEaSQnRSvSCtOfOnXtT+iVNMe98z19Kf47ig1VarHq5RyvFy1FVd/9NqxLC1dZv/5M40p+j3GCVGqteztFKFaxezuE6d+7cm4N/00r1LUt674jVxHKNVupg9TINV9t/v1r5LUt674hVAjlGSypYvVzCNbxd9WrFtyzpvSNWieQWLelg9TIIV3v/d6oV37Kk945YJZRTtLQEq2cxXItuV71a6S1Leu+IVWK5REtbsHrGwtWe9D1qpbcs6b0jVgJyiJbWYPW0h2vZ7apXK7xlSe8dsRJiPVrag9VTHK72tM9eK7xlSe8dsRJkOVpWgtXTFK5Vble9WtktS3rviJUwq9GyFqyeknC1q37eWtktS3rviJUCFqNlNVg9qXCtc7vq1YpuWdJ7R6yUsBYt68HqCYSrXfcz1opuWdJ7R6wUsRStXILVSxGuTW5XvVrJLUt674iVMlailVuwehOHq930c9VKblnSe0esFLIQrVyDVVV343BjzO+yze1q8LnEb1nSe0eslNIerRyDNUWoBtOO9PkIFrHSSXO0cgrWxKEa5XY1+KyityzpvSNWymmNVg7BmjpUg2lH/swEi1jppTFaloOVMFSj3q4Gn1/sliW9d8TKCG3RshislKEaTDvR9yBYxEo3TdGyFCyhUE1yuxp8J5FblvTeEStjtETLQrCkQjWYdoQjX/bdygwWsbJFQ7Q0B0tBqCa9XQ2+Z/JblvTeESujpKOlMVgaQjWYdoJjX/R9ywkWsbJNMlqagqUsVEluV4PvnvSWRaywFaloaQiWtlANpk1w9MNnkHewiFVeJKIlGSzFoUp6uxo8j2S3LGKFUaSOlkSwNIdqMG3qs68T3rKIFUaTMlopg2UkVCK3q8EzSnLLIlYYVapoJYqAiVANppU69zrRLYtYYXQpoqUgDozAECtMYupoSb84TIbBIlZlmzJa0i8Ok1mwiBWqarpoSb84TEbBIlYYmiJa0i8Ok0mwiBUWGTta0i8Ok0GwiBWWGTNa0i8OYzxYxAqrGCta0i8OYzhYxArrGCNa0i8OYzRYxAqb2DZa0i8OYzBYxArb2CZa0i8OYyxYxApj2DRa0i8OYyhYxApj2iRa0i8OYyRYxApTWDda0i8OYyBYxApTWida0i8OozxYxAoprBot6ReHURwsYoWUVomW9IvDKA0WsYKE06Il/eIwCoNFrCBpWbSkXxxGWbCIFTQ4KVrSLw6jKFjECposipb0i8MoCRaxgkb3R0v6xWEUBItYQbNhtKRfHEY4WMQKFvTRkn5xGMFgEStY4rrQSr84jFCwiBUsSvUbphlFQ6xgGdEqaIgVckC0ChhihZwQrYyHWCFHRCvDIVbIGdHKaIgVSkC0MhhihZIQLcNDrFAiomVwiBVKRrQMDbHCmJ682T7YzHztYnjedaG9OzE838x8/eTN9kHpz7gI0TIwSmNldeeL5aJ/oon+BRf9rVUWr+nCcRP9C7uzg89If/YhoqV4lMUql50vxs4rzz3QxHCl6fxvt1tEf+Sif+rrt69+QPo7VRXRUjlKYpXrzmft7I32va4Lz7jo3xx5Mf/mOr/Xztt3S39HoqVoFMSqhJ3P0qWXDj/29p8O0y1o04Vfudh+WPq7Ei0FoyBWJe18VvaODj/rov9HikVtov9Lc8t/Uvo7Ey3BURCrEnc+Cy6Gxya4Dp82f3Xx2ifEvzvRSj8KYlXyzpu20x2ccZ3/u8zy+jv7t68+Iv0MiFbCURArdt6oyy+3D7vo/yi5wE30Ufo5VBXRSjIKYsXOG9ZEf0N8iWOYu+h/LP0sqopoTToKYlVV7LxZLvqn5Q/tf7M7O/i89DOpKqI1ySiJFTtv1KUj/xEXw1vSBzacJvrXpJ9Lj2iNOEpixc4b1kT/A+nDWjR7M39J+tn0iNYIoyRWVcXOm7XzynMPuM7/W/qgTphXpZ/PENHaYhTFip03rJmFiwoOadk8Jv2MhojWBqMoVlXFzpvmYviZggM6cXa78D3pZ3Q/orXGKItVVbHzZl2YX3iPi/4/0ge0fPxN6ee0CNFaYRTGip037HJ3+Lj84Zw+0s/pJERrySiMVVWx86Y1XdiRPphVprn17U9LP6uTEK0FozRWVcXOm+Zm4br0wax0eJ3/ivSzWoZoDUZxrKqKnTetif670gezyuzNDp30szoN0QrqY1VV7LxpTfTfkT6YVaaJwUs/q1UUHS0Dsaoqdt40NwvPSh/MivMt6We1qiKjZSRWVcXOm9bEw30FB7PK7Eo/q3UUFS1Dsaoqdt603c5/WcHBnDpNd/BF6We1riKiZSxWVcXOm7Z/fPCo9MGsMhdfevaj0s9qE1lHy2CsqoqdN891/nXpw1n+Yvg/SD+jbWQZLaOx6rHzhrku/ET8gJZME8OPpJ/RtrKKlvFYVRU7b5qL/rz0AS2bvaPwBelnNIYsopVBrKqKnTfPdeGf0od0wgvyJ+lnMybT0cokVj123jC9L5J/WvrZjE3vsy4nVlWl+Rzy2/nRXTxuHxL4JZKnvSTZ/kmj92UpI1ZVxc6btzc7dOIHds/489LPZEomopVprHrsvHGuC7+QP7Qwb6L/qfSzSEF1tDKPVY+dN+yd34J7R/TgunB84dfPvF/6WaSiMlqFxKqq2HnzdrqDM00Mb8gcnr+zf/vqI9LPIDVV0SooVj123rjL3eHjTfT/Snx4f97pDs5If3cpKqJVYKx67LxxLvon0v0tir+ze/vax6W/szTRaBUcqx47b9z+8cGjTQy/m/Lgms7//KnfHHxQ+rtqIRItYnUXO2/cldeuvK+JwbsY3hr3JfGvN7NwsZpX75L+jtokjRax+j/sfAYuv9w+7Gb+my6GV7c7OH9zbxa+Jv19tEsSLWK1FDufiebIf66J4Ycu+t+vcmBNF150nf/+TnftU9Kf3ZJJo0Ws1sLOZ+IbL/oPNd3hV90sXHddaO/OzIfdLnyJny/ZziTRIlZbYeeBJUaNFrECMLVRokWsAKSyVbSIFYDUNooWsQIgZa1oESsA0laKFrECoMXSaBErANosjBaxAqDVPdEiVgC063/aWvpzAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQI//AplAdntdLBX1AAAAAElFTkSuQmCC\"]]", "commentAllowed":8, "payerData": { "name":{ "mandatory": false }, @@ -56,6 +56,14 @@ class LnurlPayTest { val response: HttpResponse = fakeClient(engine).get("https://acinq.co") val json = Lnurl.processLnurlResponse(response) assertEquals("payRequest", json.get("tag")!!.jsonPrimitive.content) + + val lnurl = Lnurl.parseLnurlJson(Url("https://acinq.co"), json) + assertIs(lnurl) + assertEquals(1.msat, lnurl.minSendable) + assertEquals(8_156_723_209_123.msat, lnurl.maxSendable) + assertEquals(8, lnurl.maxCommentLength) + assertEquals("'รง!@#\$%^&*()_+{}", lnurl.metadata.plainText) + assertEquals(Url("https://lnurl.fiatjaf.com/lnurl-pay/callback/0abd4cdb7b0b62e50d5a3e8704287049b3a6dff50f40ff5db821955cca00791b"), lnurl.callback) } } @@ -72,6 +80,9 @@ class LnurlPayTest { val response: HttpResponse = fakeClient(engine).get("https://acinq.co") val json = Lnurl.processLnurlResponse(response) assertEquals("payRequest", json.get("tag")!!.jsonPrimitive.content) + + val lnurl = Lnurl.parseLnurlJson(Url("https://acinq.co"), json) + assertIs(lnurl) } } diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/data/lnurl/LnurlWithdrawTest.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/data/lnurl/LnurlWithdrawTest.kt index e4bf51c47..be5c0dd2a 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/data/lnurl/LnurlWithdrawTest.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/data/lnurl/LnurlWithdrawTest.kt @@ -1,75 +1,145 @@ package fr.acinq.phoenix.data.lnurl -import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.utils.msat import io.ktor.http.* import kotlinx.serialization.json.* import kotlin.test.* class LnurlWithdrawTest { - val format = Json { ignoreUnknownKeys = true } + private val defaultLnurl = URLBuilder("https://lnurl.service.com/withdraw/token12345").build() - private val defaultDesc = "Lorem ipsum dolor sit amet" - private val defaultMin = 4000.msat - private val defaultMax = 16000.msat - private val defaultLnurl = URLBuilder("https://lnurl.fiatjaf.com/foobar").build() - private val defaultCallback = URLBuilder("https://lnurl.fiatjaf.com/lnurl-withdraw/callback/6e667d407298a7381f4bb02b228e72b3b86c0666b0662f751d089e30bd729b18").build() - private val defaultK1 = "36352c79b25544ce2cb8b7fccaaf591ed00ad42989614aafcc569ba3d384b1bb" - private val defaultWithdraw = LnurlWithdraw( - initialUrl = defaultLnurl, - callback = defaultCallback, - k1 = defaultK1, - defaultDescription = defaultDesc, - minWithdrawable = defaultMin, - maxWithdrawable = defaultMax, - ) + @Test + fun test_parseJson_can_read_valid() { + val json = """ + { + "tag":"withdrawRequest", + "callback":"$defaultLnurl/callback", + "k1":"whatever", + "defaultDescription":"๐Ÿ‘ lorem ipsum รง!@#$%^&*()_+';รฉ`ยด", + "minWithdrawable":123456789, + "maxWithdrawable":200000000000, + "some_field":123456 + } + """.trimIndent().let { Json.parseToJsonElement(it).jsonObject } + val lnurl = Lnurl.parseLnurlJson(Url("https://acinq.co"), json) + + assertIs(lnurl) + assertEquals(123_456_789.msat, lnurl.minWithdrawable) + assertEquals(200_000_000_000.msat, lnurl.maxWithdrawable) + assertEquals("\uD83D\uDC4D lorem ipsum รง!@#\$%^&*()_+';รฉ`ยด", lnurl.defaultDescription) + assertEquals("whatever", lnurl.k1) + assertEquals(Url("$defaultLnurl/callback"), lnurl.callback) + assertEquals(Url("https://acinq.co"), lnurl.initialUrl) + } - private fun makeJson( - tag: String? = "withdrawRequest", - k1: String? = defaultK1, - callback: String? = defaultCallback.toString(), - min: MilliSatoshi? = defaultMin, - max: MilliSatoshi? = defaultMax, - description: String? = defaultDesc, - ): JsonObject { - val map = mutableMapOf("randomField" to JsonPrimitive("this field should be ignored")) - tag?.let { map.put("tag", JsonPrimitive(it)) } - k1?.let { map.put("k1", JsonPrimitive(it)) } - callback?.let { map.put("callback", JsonPrimitive(it)) } - min?.let { map.put("minWithdrawable", JsonPrimitive(it.msat)) } - max?.let { map.put("maxWithdrawable", JsonPrimitive(it.msat)) } - description?.let { map.put("defaultDescription", JsonPrimitive(it)) } - return JsonObject(map) + @Test + fun test_parseJson_missing_minimum() { + val json = """ + { + "tag":"withdrawRequest", + "callback":"$defaultLnurl/callback", + "k1":"whatever", + "defaultDescription":"", + "maxWithdrawable":1234 + } + """.trimIndent().let { Json.parseToJsonElement(it).jsonObject } + val lnurl = Lnurl.parseLnurlJson(Url("https://acinq.co"), json) + assertIs(lnurl) + // min falls back to 0 + assertEquals(0.msat, lnurl.minWithdrawable) + assertEquals(1234.msat, lnurl.maxWithdrawable) } @Test - fun testJson_ok() { - val url = Lnurl.parseLnurlJson(defaultLnurl, makeJson()) - assertTrue { url is LnurlWithdraw } - assertEquals(defaultWithdraw, url) + fun test_parseJson_missing_maximum() { + val json = """ + { + "tag":"withdrawRequest", + "callback":"$defaultLnurl/callback", + "k1":"whatever", + "defaultDescription":"", + "minWithdrawable":23456 + } + """.trimIndent().let { Json.parseToJsonElement(it).jsonObject } + val lnurl = Lnurl.parseLnurlJson(Url("https://acinq.co"), json) + assertIs(lnurl) + assertEquals(23456.msat, lnurl.minWithdrawable) + // max falls back to min + assertEquals(23456.msat, lnurl.maxWithdrawable) + } + + @Test + fun test_parseJson_max_overrides_min() { + val json = """ + { + "tag":"withdrawRequest", + "callback":"$defaultLnurl/callback", + "k1":"whatever", + "defaultDescription":"", + "minWithdrawable":257, + "maxWithdrawable":256 + } + """.trimIndent().let { Json.parseToJsonElement(it).jsonObject } + val lnurl = Lnurl.parseLnurlJson(Url("https://acinq.co"), json) + assertIs(lnurl) + // min > max => min becomes max + assertEquals(256.msat, lnurl.minWithdrawable) + assertEquals(256.msat, lnurl.maxWithdrawable) + } + + @Test + fun test_parseJson_rejects_unknown_tag() { + assertFailsWith { + val json = """ + { + "tag":"withdraw", + "callback":"$defaultLnurl/callback", + "k1":"whatever", + "defaultDescription":"", + "minWithdrawable":1, + "maxWithdrawable":2 + } + """.trimIndent().let { Json.parseToJsonElement(it).jsonObject } + Lnurl.parseLnurlJson(Url("https://acinq.co"), json) + } } @Test - fun testJson_callback_unsafe() { + fun test_parseJson_rejects_unsafe_callback() { assertFailsWith(LnurlError.UnsafeResource::class) { - val json = makeJson( - callback = "http://lnurl.fiatjaf.com/lnurl-withdraw/callback/6e667d407298a7381" - ) + val json = """ + { + "tag":"withdrawRequest", + "callback":"http://lnurl.service.com/withdraw/token12345/callback", + "k1":"whatever", + "defaultDescription":"lipsum", + "minWithdrawable":123456789, + "maxWithdrawable":200000000000 + } + """.trimIndent().let { Json.parseToJsonElement(it).jsonObject } Lnurl.parseLnurlJson(defaultLnurl, json) } } @Test - fun testJson_callback_missing() { + fun test_parseJson_rejects_missing_callback() { assertFailsWith(LnurlError.MissingCallback::class) { - val json = makeJson(callback = null) + val json = """ + { + "tag":"withdrawRequest", + "k1":"whatever", + "defaultDescription":"lipsum", + "minWithdrawable":123456789, + "maxWithdrawable":200000000000 + } + """.trimIndent().let { Json.parseToJsonElement(it).jsonObject } Lnurl.parseLnurlJson(defaultLnurl, json) } } @Test - fun test_unknown_tag_lnurl() { - val lnurl = Lnurl.extractLnurl("${defaultLnurl}?tag=withdraw") + fun test_extractLnurl_ignores_unknown_tag() { + val lnurl = Lnurl.extractLnurl("$defaultLnurl?tag=withdraw") assertIs(lnurl) } } 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 77340939c..2bb948a8d 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 @@ -66,6 +66,7 @@ class IncomingPaymentDbTypeVersionTest { } @Test + @Suppress("DEPRECATION") fun incoming_receivedwith_multipart_v0_lightning() { val receivedWith = listOf(IncomingPayment.ReceivedWith.LightningPayment(100_000.msat, ByteVector32.One, 2L)) val deserialized = IncomingReceivedWithData.deserialize( @@ -90,6 +91,7 @@ class IncomingPaymentDbTypeVersionTest { } @Test + @Suppress("DEPRECATION") fun incoming_receivedwith_multipart_v0_newchannel_paytoopen() { // pay-to-open with MULTIPARTS_V0: amount contains the fee which is a special case that must be fixed when deserializing. val receivedWith = listOf(IncomingPayment.ReceivedWith.NewChannel(1_995_000.msat, 5_000.msat, 0.sat, channelId1, ByteVector32.Zeroes, confirmedAt = 0, lockedAt = 0)) @@ -116,6 +118,7 @@ class IncomingPaymentDbTypeVersionTest { } @Test + @Suppress("DEPRECATION") fun incoming_receivedwith_multipart_v0_newchannel_swapin_nochannel() { val receivedWith = listOf(IncomingPayment.ReceivedWith.NewChannel(111111111.msat, 1000.msat, 0.sat, ByteVector32.Zeroes, ByteVector32.Zeroes, confirmedAt = 0, lockedAt = 0)) val deserialized = IncomingReceivedWithData.deserialize( @@ -140,6 +143,7 @@ class IncomingPaymentDbTypeVersionTest { } @Test + @Suppress("DEPRECATION") fun incoming_receivedwith_lightning_legacy() { val deserialized = IncomingReceivedWithData.deserialize( IncomingReceivedWithTypeVersion.LIGHTNING_PAYMENT_V0, @@ -155,6 +159,7 @@ class IncomingPaymentDbTypeVersionTest { } @Test + @Suppress("DEPRECATION") fun incoming_receivedwith_newchannel_legacy() { val deserialized = IncomingReceivedWithData.deserialize( IncomingReceivedWithTypeVersion.NEW_CHANNEL_V0, 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 cb90fc473..f7426cceb 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 @@ -31,17 +31,9 @@ import kotlin.test.assertEquals class OutgoingPaymentDbTypeVersionTest { -// val part1 = LightningOutgoingPayment.ClosingTxPart( -// id = UUID.randomUUID(), -// txId = randomBytes32(), -// claimed = 123_456.sat, -// closingType = ChannelClosingType.Mutual, -// createdAt = 100 -// ) val channelId1 = randomBytes32() val address1 = "tb1q97tpc0y4rvdnu9wm7nu354lmmzdm8du228u3g4" val preimage1 = randomBytes32() - val paymentHash1 = preimage1.sha256() val paymentRequest1 = PaymentRequest.read("lntb1500n1ps9utezpp5xjfvpvgg3zykv2kdd9yws86xw5ww2kr60h9yphth2h6fly87a9gqdpzxysy2umswfjhxum0yppk76twypgxzmnwvycqp7xqrrss9qy9qsqsp5vm25lch9spq2m9fxqrgcxq0mxrgaehstd9javflyadsle5d97p9qmu9zsjn7l59lmps3568tz9ppla4xhawjptjyrw32jed84fe75z0ka0kmnntc9la95acvc0mjav6rdv5037y6zq9e0eqhenlt8y0yh8cpj467cl") @@ -65,35 +57,6 @@ class OutgoingPaymentDbTypeVersionTest { val deserialized = OutgoingDetailsData.deserialize(OutgoingDetailsTypeVersion.SWAPOUT_V0, details.mapToDb().second) assertEquals(details, deserialized) } -// -// @Test -// fun outgoing_details_closing() { -// val details = LightningOutgoingPayment.Details.ChannelClosing(channelId1, address1, false) -// val deserialized = OutgoingDetailsData.deserialize(OutgoingDetailsTypeVersion.CLOSING_V0, details.mapToDb().second) -// assertEquals(details, deserialized) -// } - -// @Test -// fun outgoing_status_success_onchain_v0_legacy() { -// val (tx1, tx2) = "7a2db73a97382d7310766e4e422339d11bbea214d7606fdbbe3578cc39754735" to "1e5e16d2bf352820515ccff0790969453d6bc06f370fe183039bf073be28623b" -// val serialized = """ -// {"txIds": ["$tx1", "$tx2"],"claimed":8643,"closingType":"Mutual"} -// """.trimIndent().encodeToByteArray() -// val deserialized = OutgoingStatusData.getClosingPartsFromV0Status(serialized, 503) -// assertEquals(tx1, deserialized[0].txId.toHex()) -// assertEquals(tx2, deserialized[1].txId.toHex()) -// assertEquals(8643.sat, deserialized[0].claimed) -// assertEquals(0.sat, deserialized[1].claimed) -// } - -// @Test -// fun outgoing_status_success_onchain() { -// val status = LightningOutgoingPayment.Status.Completed.Succeeded.OnChain(completedAt = 123) -// val deserialized1 = OutgoingStatusData.deserialize(OutgoingStatusTypeVersion.SUCCEEDED_ONCHAIN_V0, status.mapToDb().second, completedAt = 123) -// assertEquals(status, deserialized1) -// val deserialized2 = OutgoingStatusData.deserialize(OutgoingStatusTypeVersion.SUCCEEDED_ONCHAIN_V1, status.mapToDb().second, completedAt = 123) -// assertEquals(status, deserialized2) -// } @Test fun outgoing_status_success_offchain() { @@ -130,10 +93,4 @@ class OutgoingPaymentDbTypeVersionTest { assertEquals(status, deserialized) } -// @Test -// fun outgoing_part_closing_tx() { -// val deserialized = OutgoingPartClosingInfoData.deserialize(OutgoingPartClosingInfoTypeVersion.CLOSING_INFO_V0, part1.mapClosingTypeToDb().second) -// assertEquals(part1.closingType, deserialized) -// } - } \ No newline at end of file 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 8e839e9a4..82e9e31ec 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 @@ -28,7 +28,6 @@ import fr.acinq.lightning.payment.PaymentRequest import fr.acinq.lightning.utils.* import fr.acinq.lightning.wire.TemporaryNodeFailure import fr.acinq.phoenix.data.WalletPaymentFetchOptions -import fr.acinq.phoenix.db.payments.* import fr.acinq.phoenix.runTest import fr.acinq.phoenix.utils.migrations.LegacyChannelCloseHelper import fr.acinq.secp256k1.Hex @@ -234,7 +233,8 @@ class SqlitePaymentsDatabaseTest { ) assertEquals("ff7f08e8-89d1-4731-be7c-ad37c9d09afc", close.id.toString()) - assertEquals(150.sat, close.recipientAmount) + assertEquals(100.sat, close.recipientAmount) + assertEquals(50_000.msat, close.fees) assertEquals("foobar", close.address) assertEquals(ByteVector32.Zeroes, close.channelId) assertEquals(true, close.isSentToDefaultAddress) @@ -252,7 +252,7 @@ class SqlitePaymentsDatabaseTest { // Test status where a part has failed. val onePartFailed = p.copy( parts = listOf( - (p.parts[0] as LightningOutgoingPayment.Part).copy( + p.parts[0].copy( status = LightningOutgoingPayment.Part.Status.Failed(TemporaryNodeFailure.code, TemporaryNodeFailure.message, 110) ), p.parts[1] @@ -290,9 +290,9 @@ class SqlitePaymentsDatabaseTest { val partsSettled = withMoreParts.copy( parts = listOf( withMoreParts.parts[0], // this one was failed - (withMoreParts.parts[1] as LightningOutgoingPayment.Part).copy(status = LightningOutgoingPayment.Part.Status.Succeeded(preimage, 125)), - (withMoreParts.parts[2] as LightningOutgoingPayment.Part).copy(status = LightningOutgoingPayment.Part.Status.Succeeded(preimage, 126)), - (withMoreParts.parts[3] as LightningOutgoingPayment.Part).copy(status = LightningOutgoingPayment.Part.Status.Succeeded(preimage, 127)), + withMoreParts.parts[1].copy(status = LightningOutgoingPayment.Part.Status.Succeeded(preimage, 125)), + withMoreParts.parts[2].copy(status = LightningOutgoingPayment.Part.Status.Succeeded(preimage, 126)), + withMoreParts.parts[3].copy(status = LightningOutgoingPayment.Part.Status.Succeeded(preimage, 127)), ) ) assertEquals(LightningOutgoingPayment.Status.Pending, partsSettled.status) @@ -359,7 +359,7 @@ class SqlitePaymentsDatabaseTest { p.copy(recipientAmount = 1000.msat).let { assertFails { db.addOutgoingPayment(it) } } - p.copy(id = UUID.randomUUID(), parts = p.parts.map { (it as LightningOutgoingPayment.Part).copy(id = p.parts[0].id) }).let { + p.copy(id = UUID.randomUUID(), parts = p.parts.map { it.copy(id = p.parts[0].id) }).let { assertFails { db.addOutgoingPayment(it) } } } 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 4975aaebc..8b575f201 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 @@ -12,7 +12,7 @@ import fr.acinq.phoenix.runTest import fr.acinq.secp256k1.Hex import kotlin.test.* -@Ignore + class CloudDataTest { private val preimage = randomBytes32() @@ -45,7 +45,12 @@ class CloudDataTest { // attempt to deserialize & extract payment val data = CloudData.cborDeserialize(blob) assertNotNull(data) - val decoded = data.outgoing?.unwrap() + val decoded = when (outgoingPayment) { + is LightningOutgoingPayment -> data.outgoing?.unwrap() + is SpliceOutgoingPayment -> data.spliceOutgoing?.unwrap() + is ChannelCloseOutgoingPayment -> data.channelClose?.unwrap() + is SpliceCpfpOutgoingPayment -> data.spliceCpfp?.unwrap() + } assertNotNull(decoded) // test equality (no loss of information) @@ -122,7 +127,6 @@ class CloudDataTest { @Test fun incoming__receivedWith_newChannel_legacy_no_uuid() = runTest { val blob = Hex.decode("bf6169bf68707265696d6167655820d77a5c6e17f70240c4a2aaf54fb1389188e482e85247f63c80417661e6a9b250666f726967696ebf64747970656a494e564f4943455f563064626c6f6259011b7b227061796d656e7452657175657374223a226c6e6263333530753170336464347874707035716c706868353476396e366737323439636435613463337a6735666b666b336a657377356370306b6a70393264366e377574777364717664396838766d6d6676646a73637170737370357a6a7275777a6c7677353732616e3934776b6b7630616370657077773068647a387134726466673679756b776466647032723571397179397173717738337771653868797130767061636a78336c7963777567657765787a307a3634796732343564377a717277653039676c7572716e34706466306d706539766336713065667739637533613773746575786c6a7730786e7970336d686a6565786664677a787163703564646d6b61227dff687265636569766564bf6274731b00000182172f3a3764747970656d4d554c544950415254535f563164626c6f625901ab5b7b2274797065223a2266722e6163696e712e70686f656e69782e64622e7061796d656e74732e496e636f6d696e67526563656976656457697468446174612e506172742e4e65774368616e6e656c2e5630222c22616d6f756e74223a7b226d736174223a373030303030307d2c2266656573223a7b226d736174223a333030303030307d2c226368616e6e656c4964223a2265386130653762613931613438356564363835373431356363306336306637376564613663623165626531646138343164343264376234333838636332626363227d2c7b2274797065223a2266722e6163696e712e70686f656e69782e64622e7061796d656e74732e496e636f6d696e67526563656976656457697468446174612e506172742e4e65774368616e6e656c2e5630222c22616d6f756e74223a7b226d736174223a393030303030307d2c2266656573223a7b226d736174223a363030303030307d2c226368616e6e656c4964223a2265386130653762613931613438356564363835373431356363306336306637376564613663623165626531646138343164343264376234333838636332626363227d5dff696372656174656441741b00000182172f3a37ff616ff6617600617040ff") - val data = CloudData.cborDeserialize(blob) assertNotNull(data) val decoded = data.incoming?.unwrap() @@ -132,8 +136,8 @@ class CloudDataTest { val expectedChannelId = Hex.decode("e8a0e7ba91a485ed6857415cc0c60f77eda6cb1ebe1da841d42d7b4388cc2bcc").byteVector32() val expectedReceived = IncomingPayment.Received( receivedWith = listOf( - IncomingPayment.ReceivedWith.NewChannel(amount = 7_000_000.msat, miningFee = 1000.sat, serviceFee = 3_000_000.msat, channelId = expectedChannelId, txId = randomBytes32(), confirmedAt = 1658246356984, lockedAt = 1658246356984), - IncomingPayment.ReceivedWith.NewChannel(amount = 9_000_000.msat, miningFee = 1000.sat, serviceFee = 6_000_000.msat, channelId = expectedChannelId, txId = randomBytes32(), confirmedAt = 1658246357123, lockedAt = 1658246357123) + IncomingPayment.ReceivedWith.NewChannel(amount = 7_000_000.msat, miningFee = 0.sat, serviceFee = 3_000_000.msat, channelId = expectedChannelId, txId = ByteVector32.Zeroes, confirmedAt = 0, lockedAt = 0), + IncomingPayment.ReceivedWith.NewChannel(amount = 9_000_000.msat, miningFee = 0.sat, serviceFee = 6_000_000.msat, channelId = expectedChannelId, txId = ByteVector32.Zeroes, confirmedAt = 0, lockedAt = 0) ), receivedAt = 1658246347319 ) @@ -307,6 +311,8 @@ class CloudDataTest { /** * Payments for channel closings created before lightning-kmp v1.3.0 may be using [PublicKey.Generator] for the recipient field. In that case, the public key will * be uncompressed and will be saved as such in the cloud. We still should be able to read it. + * + * Note that with the splices update, the recipient node id has been removed from the channel-closing object. So we just check we can read the data. */ @Test fun read_legacy_uncompressed_pubkey() { @@ -316,9 +322,10 @@ class CloudDataTest { assertEquals(65, cloudData.outgoing?.recipient?.size) val payment = cloudData.outgoing?.unwrap() assertNotNull(payment) - assertIs(payment) - assertEquals(33, payment.recipient.value.size()) - assertEquals(33, CloudData(payment).outgoing?.recipient?.size) + assertIs(payment) + assertEquals(27001000.msat, payment.amount) + assertEquals("3GZiZZs8QGrH4za8ZrQkXdrqDj2fHd1ijy", payment.address) + assertEquals(ByteVector32.fromValidHex("ff7f08e889d1b731be7cad37c9d09afc1515a1863979a64d0dbb652134099b89"), payment.channelId) } @Test @@ -336,7 +343,12 @@ class CloudDataTest { assertIs(decoded) assertEquals(50_000_000.msat, decoded.amount) assertEquals(2_000_000.msat, decoded.fees) - TODO("check mapping to ChannelCloseOutgoingPayment") + assertEquals("2N3mCs4rJGgwZ4h1dY38Hf7vLiwZLDVNzh4", decoded.address) + assertEquals(ByteVector32.fromValidHex("80cb832d2fc617db9022a3c29b42808fb2b126ec1be88bd0e480974e74c04b78"), decoded.channelId) + assertEquals(false, decoded.isSentToDefaultAddress) + assertEquals(1653500781802, decoded.createdAt) + assertEquals(1653500781802, decoded.confirmedAt) + assertEquals(1653500781802, decoded.lockedAt) } @Test @@ -401,66 +413,6 @@ class CloudDataTest { assertEquals("Fake lnurl-pay", lnurlPay.description) } - @Test - fun outgoing__lnurl_pay() { - val paymentBlob = Hex.decode( - "bf6169f6616fbf626964782465366662336662612d666461332d346163392d396633382d656239356234633064353962646d7361741a000f424069726563697069656e7458210397eabc70be6e6e9dd831d7887bf579fdf6500f0f0e07ed8922e64471ee39f1fb6764657461696c73bf6474797065694e4f524d414c5f563064626c6f625901a07b227061796d656e7452657175657374223a226c6e746231307531703336797934787070357134303539613937636a6367637232376d3235366677387132377064633671377074786c643364736a65306c666c3971776d6d736471686765736b6b6566716433683832756e76393463787a37676371706a73703534747a666b37346d73613565677473356b393333617a343261646a6b7a726b716e707072396b74767a75686a6d743238777967733971377371717171717171717171717171717171717171737171717171797367716d717a39677871797a357671727a6a7177666e33703932373874747a7a70653065303075687978686e6564336a356439616371616b35656d776670666c70387a32636e666c6c7867336337757730336c76717171716c6771717171716571716a7137356a616c6d70787a653074667170766b656b7336327171766b3465337a6467683077726d6b72707061327433377a33376472686371306361346336343733363964666e74657239737a3778666774646e306a656163356d3334786668667a63616c65673570737073616d7a3564227dff6570617274739fbf626964782434366631383031302d323066652d343538302d623431622d613137396564663337363637646d7361741a000f4e5c65726f7574657901183033393765616263373062653665366539646438333164373838376266353739666466363530306630663065303765643839323265363434373165653339663166623a3033393333383834616166316436623130383339376535656665356338366263663264386361386432663730306564613939646239323134666332373132623133343a30783839383337313278303b3033393333383834616166316436623130383339376535656665356338366263663264386361386432663730306564613939646239323134666332373132623133343a3033393765616263373062653665366539646438333164373838376266353739666466363530306630663065303765643839323265363434373165653339663166623a66737461747573bf6274731b000001853118ebff64747970656c5355434345454445445f563064626c6f62584f7b22707265696d616765223a2263643065333562343337393632363066653162343535333631346639363866663732653936633833396632376337383864353435656661353362613133373030227dff696372656174656441741b000001853118e314ffff66737461747573bf6274731b000001853118ee256474797065755355434345454445445f4f4646434841494e5f563064626c6f62584f7b22707265696d616765223a2263643065333562343337393632363066653162343535333631346639363866663732653936633833396632376337383864353435656661353362613133373030227dff696372656174656441741b000001853118dc2fff61760061705874580a3af70036ff704b08ef289b3db31f9b090dca2da10180d9e7196a98119b777d72c178511f96652dd02e30eec72656b4041692625a8eaedab9fb780ab1cab350ef1e71cffbec3b38cfc3dc43aa8b3ef70c306fc12f5fb6a3b494cac4d35a016ddb1967a24c34796669381af63c0ab2c07f8831ff" - ) - val metadataBlob = Hex.decode( - "bf6176016a6c6e75726c5f62617365bf6474797065665041595f563064626c6f62587fbf656c6e75726c7768747470733a2f2f66616b652e69742f696e697469616c6863616c6c6261636b781868747470733a2f2f66616b652e69742f63616c6c6261636b6f6d696e53656e6461626c654d7361741a000f42406f6d617853656e6461626c654d7361741a001e8480706d6178436f6d6d656e744c656e677468f6ffff6e6c6e75726c5f6d65746164617461bf6474797065665041595f563064626c6f62582abf6372617778225b5b22746578742f706c61696e222c202246616b65206c6e75726c2d706179225d5dffff736c6e75726c5f73756363657373416374696f6ef6716c6e75726c5f6465736372697074696f6e6e46616b65206c6e75726c2d70617970757365725f6465736372697074696f6ef66a757365725f6e6f746573606d6f726967696e616c5f66696174bf6474797065635553446472617465fb40d07e27ae147ae1ffff" - ) - - val paymentData = CloudData.cborDeserialize(paymentBlob) - assertNotNull(paymentData) - val outgoingPayment = paymentData.outgoing?.unwrap() - assertNotNull(outgoingPayment) - assertEquals(1_003_100.msat, outgoingPayment.amount) - assertEquals(1_000_000.msat, outgoingPayment.recipientAmount) - assertEquals(3_100.msat, outgoingPayment.fees) - assertEquals(1, outgoingPayment.parts.filterIsInstance().size) - - val metadataRow = CloudAsset.cborDeserialize(metadataBlob) - assertNotNull(metadataRow) - val metadata = metadataRow.unwrap().deserialize() - val lnurlPay = metadata.lnurl - assertNotNull(lnurlPay) - assertEquals("https://fake.it/initial", lnurlPay.pay.initialUrl.toString()) - assertEquals(1_000_000.msat, lnurlPay.pay.minSendable) - assertEquals(2_000_000.msat, lnurlPay.pay.maxSendable) - assertEquals("Fake lnurl-pay", lnurlPay.pay.metadata.plainText) - assertEquals("Fake lnurl-pay", lnurlPay.description) - } - - @Test - fun outgoing__lnurl_pay_legacy() { - val paymentBlob = Hex.decode( - "bf6169f6616fbf626964782465366662336662612d666461332d346163392d396633382d656239356234633064353962646d7361741a000f424069726563697069656e7458210397eabc70be6e6e9dd831d7887bf579fdf6500f0f0e07ed8922e64471ee39f1fb6764657461696c73bf6474797065694e4f524d414c5f563064626c6f625901a07b227061796d656e7452657175657374223a226c6e746231307531703336797934787070357134303539613937636a6367637232376d3235366677387132377064633671377074786c643364736a65306c666c3971776d6d736471686765736b6b6566716433683832756e76393463787a37676371706a73703534747a666b37346d73613565677473356b393333617a343261646a6b7a726b716e707072396b74767a75686a6d743238777967733971377371717171717171717171717171717171717171737171717171797367716d717a39677871797a357671727a6a7177666e33703932373874747a7a70653065303075687978686e6564336a356439616371616b35656d776670666c70387a32636e666c6c7867336337757730336c76717171716c6771717171716571716a7137356a616c6d70787a653074667170766b656b7336327171766b3465337a6467683077726d6b72707061327433377a33376472686371306361346336343733363964666e74657239737a3778666774646e306a656163356d3334786668667a63616c65673570737073616d7a3564227dff6570617274739fbf626964782434366631383031302d323066652d343538302d623431622d613137396564663337363637646d7361741a000f4e5c65726f7574657901183033393765616263373062653665366539646438333164373838376266353739666466363530306630663065303765643839323265363434373165653339663166623a3033393333383834616166316436623130383339376535656665356338366263663264386361386432663730306564613939646239323134666332373132623133343a30783839383337313278303b3033393333383834616166316436623130383339376535656665356338366263663264386361386432663730306564613939646239323134666332373132623133343a3033393765616263373062653665366539646438333164373838376266353739666466363530306630663065303765643839323265363434373165653339663166623a66737461747573bf6274731b000001853118ebff64747970656c5355434345454445445f563064626c6f62584f7b22707265696d616765223a2263643065333562343337393632363066653162343535333631346639363866663732653936633833396632376337383864353435656661353362613133373030227dff696372656174656441741b000001853118e314ffff66737461747573bf6274731b000001853118ee256474797065755355434345454445445f4f4646434841494e5f563064626c6f62584f7b22707265696d616765223a2263643065333562343337393632363066653162343535333631346639363866663732653936633833396632376337383864353435656661353362613133373030227dff696372656174656441741b000001853118dc2fff61760061705874580a3af70036ff704b08ef289b3db31f9b090dca2da10180d9e7196a98119b777d72c178511f96652dd02e30eec72656b4041692625a8eaedab9fb780ab1cab350ef1e71cffbec3b38cfc3dc43aa8b3ef70c306fc12f5fb6a3b494cac4d35a016ddb1967a24c34796669381af63c0ab2c07f8831ff" - ) - val metadataBlob = Hex.decode( - "bf6176016a6c6e75726c5f62617365bf6474797065665041595f563064626c6f62587fbf656c6e75726c7768747470733a2f2f66616b652e69742f696e697469616c6863616c6c6261636b781868747470733a2f2f66616b652e69742f63616c6c6261636b6f6d696e53656e6461626c654d7361741a000f42406f6d617853656e6461626c654d7361741a001e8480706d6178436f6d6d656e744c656e677468f6ffff6e6c6e75726c5f6d65746164617461bf6474797065665041595f563064626c6f62582abf6372617778225b5b22746578742f706c61696e222c202246616b65206c6e75726c2d706179225d5dffff736c6e75726c5f73756363657373416374696f6ef6716c6e75726c5f6465736372697074696f6e6e46616b65206c6e75726c2d70617970757365725f6465736372697074696f6ef66a757365725f6e6f746573606d6f726967696e616c5f66696174bf6474797065635553446472617465fb40d073d51eb851ecffff" - ) - - val paymentData = CloudData.cborDeserialize(paymentBlob) - assertNotNull(paymentData) - val outgoingPayment = paymentData.outgoing?.unwrap() - assertNotNull(outgoingPayment) - assertEquals(1_003_100.msat, outgoingPayment.amount) - assertEquals(1_000_000.msat, outgoingPayment.recipientAmount) - assertEquals(3_100.msat, outgoingPayment.fees) - assertEquals(1, outgoingPayment.parts.filterIsInstance().size) - - val metadataRow = CloudAsset.cborDeserialize(metadataBlob) - assertNotNull(metadataRow) - val metadata = metadataRow.unwrap().deserialize() - val lnurlPay = metadata.lnurl - assertNotNull(lnurlPay) - assertEquals("https://fake.it/initial", lnurlPay.pay.initialUrl.toString()) - assertEquals(1_000_000.msat, lnurlPay.pay.minSendable) - assertEquals(2_000_000.msat, lnurlPay.pay.maxSendable) - assertEquals("Fake lnurl-pay", lnurlPay.pay.metadata.plainText) - assertEquals("Fake lnurl-pay", lnurlPay.description) - } - companion object { private val defaultFeatures = Features( Feature.VariableLengthOnion to FeatureSupport.Optional, 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 09d48f76d..301fc2316 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 @@ -74,11 +74,11 @@ class ParserTest { @Ignore fun parse_bitcoin_uri_chain_mismatch() { assertEquals( - expected = Either.Left(BitcoinAddressError.ChainMismatch(expected = NodeParams.Chain.Testnet, actual = NodeParams.Chain.Mainnet)), + expected = Either.Left(BitcoinAddressError.ChainMismatch(expected = NodeParams.Chain.Testnet)), actual = Parser.readBitcoinAddress(NodeParams.Chain.Testnet, "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3") ) assertEquals( - expected = Either.Left(BitcoinAddressError.ChainMismatch(expected = NodeParams.Chain.Mainnet, actual = NodeParams.Chain.Testnet)), + expected = Either.Left(BitcoinAddressError.ChainMismatch(expected = NodeParams.Chain.Mainnet)), actual = Parser.readBitcoinAddress(NodeParams.Chain.Mainnet, "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx") ) } diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitDb.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitDb.kt index 37cc28542..983983684 100644 --- a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitDb.kt +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitDb.kt @@ -79,6 +79,9 @@ class CloudKitDb( val ckQueries = database.cloudKitPaymentsQueries val inQueries = IncomingQueries(database) val outQueries = OutgoingQueries(database) + val spliceOutgoingQueries = SpliceOutgoingQueries(database) + val channelCloseOutgoingQueries = ChannelCloseOutgoingQueries(database) + val spliceCpfpOutgoingQueries = SpliceCpfpOutgoingQueries(database) val metaQueries = MetadataQueries(database) val rowids = mutableListOf() @@ -101,32 +104,29 @@ class CloudKitDb( batch.forEach { row -> rowids.add(row.rowid) - when (row.type) { - WalletPaymentId.DbType.INCOMING.value -> { - try { + try { + val paymentId: WalletPaymentId? = when (row.type) { + WalletPaymentId.DbType.INCOMING.value -> { WalletPaymentId.IncomingPaymentId.fromString(row.id) - } catch (e: Exception) { - null } - } - WalletPaymentId.DbType.OUTGOING.value -> { - try { + WalletPaymentId.DbType.OUTGOING.value -> { WalletPaymentId.LightningOutgoingPaymentId.fromString(row.id) - } catch (e: Exception) { - null } - } - WalletPaymentId.DbType.SPLICE_OUTGOING.value -> { - try { + WalletPaymentId.DbType.SPLICE_OUTGOING.value -> { WalletPaymentId.SpliceOutgoingPaymentId.fromString(row.id) - } catch (e: Exception) { - null } + WalletPaymentId.DbType.CHANNEL_CLOSE_OUTGOING.value -> { + WalletPaymentId.ChannelCloseOutgoingPaymentId.fromString(row.id) + } + WalletPaymentId.DbType.SPLICE_CPFP_OUTGOING.value -> { + WalletPaymentId.SpliceCpfpOutgoingPaymentId.fromString(row.id) + } + else -> null } - else -> null - }?.let { paymentId -> - rowidMap[row.rowid] = paymentId - } + paymentId?.let { + rowidMap[row.rowid] = it + } + } catch (e: Exception) {} } // // Remember: there could be duplicates @@ -134,38 +134,80 @@ class CloudKitDb( // Step 3 of 5: // Fetch the corresponding payment info from the database. - // Depending on the type of WalletPaymentId, this will be either: - // - IncomingPayment - // - OutgoingPayment - // // In order to optimize disk access, we fetch from 1 table at a time. val metadataPlaceholder = WalletPaymentMetadata() val emptyOptions = WalletPaymentFetchOptions.None - uniquePaymentIds.filterIsInstance().forEach { paymentId -> - inQueries.getIncomingPayment( - paymentHash = paymentId.paymentHash - )?.let { payment -> - rowMap[paymentId] = WalletPaymentInfo( - payment = payment, - metadata = metadataPlaceholder, - fetchOptions = emptyOptions - ) - } - } // - - uniquePaymentIds.filterIsInstance().forEach { paymentId -> - outQueries.getPaymentRelaxed( - id = paymentId.id - )?.let { payment -> - rowMap[paymentId] = WalletPaymentInfo( - payment = payment, - metadata = metadataPlaceholder, - fetchOptions = emptyOptions - ) - } - } // + uniquePaymentIds + .filterIsInstance() + .forEach { paymentId -> + inQueries.getIncomingPayment( + paymentHash = paymentId.paymentHash + )?.let { payment -> + rowMap[paymentId] = WalletPaymentInfo( + payment = payment, + metadata = metadataPlaceholder, + fetchOptions = emptyOptions + ) + } + } // + + uniquePaymentIds + .filterIsInstance() + .forEach { paymentId -> + outQueries.getPaymentRelaxed( + id = paymentId.id + )?.let { payment -> + rowMap[paymentId] = WalletPaymentInfo( + payment = payment, + metadata = metadataPlaceholder, + fetchOptions = emptyOptions + ) + } + } // + + uniquePaymentIds + .filterIsInstance() + .forEach { paymentId -> + spliceOutgoingQueries.getSpliceOutPayment( + id = paymentId.id + )?.let { payment -> + rowMap[paymentId] = WalletPaymentInfo( + payment = payment, + metadata = metadataPlaceholder, + fetchOptions = emptyOptions + ) + } + } // + + uniquePaymentIds + .filterIsInstance() + .forEach { paymentId -> + channelCloseOutgoingQueries.getChannelCloseOutgoingPayment( + id = paymentId.id + )?.let { payment -> + rowMap[paymentId] = WalletPaymentInfo( + payment = payment, + metadata = metadataPlaceholder, + fetchOptions = emptyOptions + ) + } + } // + + uniquePaymentIds + .filterIsInstance() + .forEach { paymentId -> + spliceCpfpOutgoingQueries.getCpfp( + id = paymentId.id + )?.let { payment -> + rowMap[paymentId] = WalletPaymentInfo( + payment = payment, + metadata = metadataPlaceholder, + fetchOptions = emptyOptions + ) + } + } // val fetchOptions = WalletPaymentFetchOptions.All uniquePaymentIds.forEach { paymentId -> @@ -220,6 +262,12 @@ class CloudKitDb( incoming.add(row.unpadded_size) WalletPaymentId.DbType.OUTGOING.value -> outgoing.add(row.unpadded_size) + WalletPaymentId.DbType.SPLICE_OUTGOING.value -> + outgoing.add(row.unpadded_size) + WalletPaymentId.DbType.CHANNEL_CLOSE_OUTGOING.value -> + outgoing.add(row.unpadded_size) + WalletPaymentId.DbType.SPLICE_CPFP_OUTGOING.value -> + outgoing.add(row.unpadded_size) } } } @@ -324,10 +372,11 @@ class CloudKitDb( // Perhaps because the List was created in Swift ? // The workaround seems to be to copy the list here, // or otherwise process it outside of the `withContext` below. - val incomingList = downloadedPayments.mapNotNull { it as? IncomingPayment } - val outgoingList = downloadedPayments.mapNotNull { it as? LightningOutgoingPayment } + val incomingList = downloadedPayments.filterIsInstance() + val outgoingList = downloadedPayments.filterIsInstance() val spliceOutList = downloadedPayments.filterIsInstance() val channelCloseList = downloadedPayments.filterIsInstance() + val spliceCpfpList = downloadedPayments.filterIsInstance() // We are seeing crashes when accessing the ByteArray values in updateMetadata. // So we need a workaround. @@ -338,6 +387,9 @@ class CloudKitDb( val ckQueries = database.cloudKitPaymentsQueries val inQueries = database.incomingPaymentsQueries val outQueries = database.outgoingPaymentsQueries + val spliceOutQueries = SpliceOutgoingQueries(database) + val channelCloseOutQueries = ChannelCloseOutgoingQueries(database) + val spliceCpfpQueries = SpliceCpfpOutgoingQueries(database) val metaQueries = database.paymentsMetadataQueries database.transaction { @@ -437,12 +489,79 @@ class CloudKitDb( } } // - spliceOutList.forEach { - TODO("handle splice outs") + spliceOutList.forEach { payment -> + + val existing = spliceOutQueries.getSpliceOutPayment(id = payment.id) + if (existing == null) { + spliceOutQueries.addSpliceOutgoingPayment(payment = payment) + + } else { + val confirmedAt = payment.confirmedAt + if (existing.confirmedAt == null && confirmedAt != null) { + spliceOutQueries.setConfirmed( + id = payment.id, + confirmedAt = confirmedAt + ) + } + + val lockedAt = payment.lockedAt + if (existing.lockedAt == null && lockedAt != null) { + spliceOutQueries.setLocked( + id = payment.id, + lockedAt = lockedAt + ) + } + } + } + + channelCloseList.forEach { payment -> + + val existing = channelCloseOutQueries.getChannelCloseOutgoingPayment(id = payment.id) + if (existing == null) { + channelCloseOutQueries.addChannelCloseOutgoingPayment(payment = payment) + + } else { + val confirmedAt = payment.confirmedAt + if (existing.confirmedAt == null && confirmedAt != null) { + channelCloseOutQueries.setConfirmed( + id = payment.id, + confirmedAt = confirmedAt + ) + } + + val lockedAt = payment.lockedAt + if (existing.lockedAt == null && lockedAt != null) { + channelCloseOutQueries.setLocked( + id = payment.id, + lockedAt = lockedAt + ) + } + } } - channelCloseList.forEach { - TODO("handle closing") + spliceCpfpList.forEach { payment -> + + val existing = spliceCpfpQueries.getCpfp(id = payment.id) + if (existing == null) { + spliceCpfpQueries.addCpfpPayment(payment = payment) + + } else { + val confirmedAt = payment.confirmedAt + if (existing.confirmedAt == null && confirmedAt != null) { + spliceCpfpQueries.setConfirmed( + id = payment.id, + confirmedAt = confirmedAt + ) + } + + val lockedAt = payment.lockedAt + if (existing.lockedAt == null && lockedAt != null) { + spliceCpfpQueries.setLocked( + id = payment.id, + lockedAt = lockedAt + ) + } + } } downloadedPaymentsMetadata.forEach { (paymentId, row) -> diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt index 344c16cbc..320ac6587 100644 --- a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt @@ -6,7 +6,7 @@ import fr.acinq.phoenix.db.payments.CloudKitInterface import fracinqphoenixdb.Cloudkit_payments_queue -actual fun didCompleteWalletPayment(id: WalletPaymentId, database: PaymentsDatabase) { +actual fun didSaveWalletPayment(id: WalletPaymentId, database: PaymentsDatabase) { database.cloudKitPaymentsQueries.addToQueue( type = id.dbType.value, id = id.dbId, 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 730221a46..8320e689a 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,10 +1,14 @@ package fr.acinq.phoenix.utils +import fr.acinq.bitcoin.ByteVector32 import fr.acinq.lightning.ChannelEvents import fr.acinq.lightning.LiquidityEvents import fr.acinq.lightning.NodeEvents +import fr.acinq.lightning.blockchain.electrum.ElectrumClient import fr.acinq.lightning.blockchain.electrum.ElectrumMiniWallet import fr.acinq.lightning.blockchain.electrum.WalletState +import fr.acinq.lightning.blockchain.electrum.getConfirmations +import fr.acinq.lightning.channel.ChannelCommand import fr.acinq.lightning.channel.states.Aborted import fr.acinq.lightning.channel.states.ChannelState import fr.acinq.lightning.channel.states.Closed @@ -202,3 +206,62 @@ fun LiquidityPolicy.asAuto(): LiquidityPolicy.Auto? = when (this) { is LiquidityPolicy.Auto -> this else -> null } + +fun ChannelCommand.Commitment.Splice.Response.asFailure(): ChannelCommand.Commitment.Splice.Response.Failure? = when (this) { + is ChannelCommand.Commitment.Splice.Response.Failure -> this + else -> null +} + +fun ChannelCommand.Commitment.Splice.Response.Failure.asInsufficientFunds(): ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds? = when (this) { + is ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds -> this + else -> null +} + +fun ChannelCommand.Commitment.Splice.Response.Failure.asInvalidSpliceOutPubKeyScript(): ChannelCommand.Commitment.Splice.Response.Failure.InvalidSpliceOutPubKeyScript? = when (this) { + is ChannelCommand.Commitment.Splice.Response.Failure.InvalidSpliceOutPubKeyScript -> this + else -> null +} + +fun ChannelCommand.Commitment.Splice.Response.Failure.asSpliceAlreadyInProgress(): ChannelCommand.Commitment.Splice.Response.Failure.SpliceAlreadyInProgress? = when (this) { + is ChannelCommand.Commitment.Splice.Response.Failure.SpliceAlreadyInProgress -> this + else -> null +} + +fun ChannelCommand.Commitment.Splice.Response.Failure.asChannelNotIdle(): ChannelCommand.Commitment.Splice.Response.Failure.ChannelNotIdle? = when (this) { + is ChannelCommand.Commitment.Splice.Response.Failure.ChannelNotIdle -> this + else -> null +} + +fun ChannelCommand.Commitment.Splice.Response.Failure.asFundingFailure(): ChannelCommand.Commitment.Splice.Response.Failure.FundingFailure? = when (this) { + is ChannelCommand.Commitment.Splice.Response.Failure.FundingFailure -> this + else -> null +} + +fun ChannelCommand.Commitment.Splice.Response.Failure.asCannotStartSession(): ChannelCommand.Commitment.Splice.Response.Failure.CannotStartSession? = when (this) { + is ChannelCommand.Commitment.Splice.Response.Failure.CannotStartSession -> this + else -> null +} + +fun ChannelCommand.Commitment.Splice.Response.Failure.asInteractiveTxSessionFailed(): ChannelCommand.Commitment.Splice.Response.Failure.InteractiveTxSessionFailed? = when (this) { + is ChannelCommand.Commitment.Splice.Response.Failure.InteractiveTxSessionFailed -> this + else -> null +} + +fun ChannelCommand.Commitment.Splice.Response.Failure.asCannotCreateCommitTx(): ChannelCommand.Commitment.Splice.Response.Failure.CannotCreateCommitTx? = when (this) { + is ChannelCommand.Commitment.Splice.Response.Failure.CannotCreateCommitTx -> this + else -> null +} + +fun ChannelCommand.Commitment.Splice.Response.Failure.asAbortedByPeer(): ChannelCommand.Commitment.Splice.Response.Failure.AbortedByPeer? = when (this) { + is ChannelCommand.Commitment.Splice.Response.Failure.AbortedByPeer -> this + else -> null +} + +fun ChannelCommand.Commitment.Splice.Response.Failure.asDisconnected(): ChannelCommand.Commitment.Splice.Response.Failure.Disconnected? = when (this) { + is ChannelCommand.Commitment.Splice.Response.Failure.Disconnected -> this + else -> null +} + +suspend fun ElectrumClient.kotlin_getConfirmations(txid: ByteVector32): Int? { + return this.getConfirmations(txid) +}