diff --git a/.github/workflows/release-internal.yml b/.github/workflows/release-internal.yml index 8099065f11..1a042d86ca 100644 --- a/.github/workflows/release-internal.yml +++ b/.github/workflows/release-internal.yml @@ -73,6 +73,8 @@ jobs: KEYSTORE_PASSWORD: ${{ secrets.INTERNAL_KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.INTERNAL_KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.INTERNAL_KEY_PASSWORD }} + # Optional: without it the build passes and Spark (Lightning without channels) stays off. + BREEZ_API_KEY: ${{ secrets.BREEZ_API_KEY }} run: | set -euo pipefail ./gradlew assembleMainnetRelease --no-daemon --stacktrace diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index edcca33abc..a957db809d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,6 +66,15 @@ jobs: printf '%s' "$BITKIT_KEYSTORE_BASE64" | base64 --decode > "$keystore_path" echo "KEYSTORE_FILE=$keystore_path" >> "$GITHUB_ENV" + - name: Check Breez API key + env: + BREEZ_API_KEY: ${{ secrets.BREEZ_API_KEY }} + run: | + if [ -z "${BREEZ_API_KEY// /}" ]; then + echo "::error::BREEZ_API_KEY is missing from the release environment; a release without it ships with Spark (Lightning without channels) off." + exit 1 + fi + - name: Build release artifacts env: GPR_USER: ${{ secrets.GPR_USER || github.actor }} @@ -74,6 +83,7 @@ jobs: KEYSTORE_PASSWORD: ${{ secrets.BITKIT_KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.BITKIT_KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.BITKIT_KEY_PASSWORD }} + BREEZ_API_KEY: ${{ secrets.BREEZ_API_KEY }} run: | set -euo pipefail ./gradlew assembleMainnetRelease bundleMainnetRelease --no-daemon --stacktrace diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b0140d5d77..451d0593fd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -357,6 +357,8 @@ androidComponents { "USDT_RPC_URL", localProp("USDT_RPC_URL").orElse("https://bitkit.to/v1/usdt/chain-rpc").stringField(), ) + buildConfigFields.put("BREEZ_API_KEY", localProp("BREEZ_API_KEY").orElse("").stringField()) + buildConfigFields.put("ESPLORA_URL", localProp("ESPLORA_URL").orElse("").stringField()) buildConfigFields.put("E2E", e2eEnv.booleanField()) buildConfigFields.put("E2E_BACKEND", e2eBackendEnv.stringField()) buildConfigFields.put("E2E_LOCAL_HOST", e2eLocalHostEnv.stringField()) @@ -564,6 +566,7 @@ dependencies { implementation(libs.bitkit.core) } implementation(libs.paykit) + implementation(libs.breez.sdk.spark) { exclude(group = "net.java.dev.jna", module = "jna") } implementation(libs.vss.client) if (localBitkitCoreAar == null) nativeDebugSymbols(libs.bitkit.core.nativeDebugSymbolsArtifact()) nativeDebugSymbols(libs.ldk.node.android.nativeDebugSymbolsArtifact()) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/transfer/FundingScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/transfer/FundingScreenTest.kt index a062322016..2397670d96 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/transfer/FundingScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/transfer/FundingScreenTest.kt @@ -120,6 +120,20 @@ class FundingScreenTest { assertEquals(0, manualClicks) } + @Test + fun whenGeoBlocked_onlyManualSetupShouldInvoke() { + setContent(isGeoBlocked = true) + + clickableTransferOption().performClick() + composeTestRule.onNodeWithTag("FundReceive").performClick() + composeTestRule.onNodeWithTag("FundManual").performClick() + composeTestRule.waitForIdle() + + assertEquals(0, transferClicks) + assertEquals(0, fundClicks) + assertEquals(1, manualClicks) + } + @Test fun whenFundableBalanceIsZero_transferShouldNotRouteToTheTransferFlow() { setContent(fundableBalance = 0uL) diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 1c101a1b07..5bb1c1758d 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -12,6 +12,7 @@ import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat +import com.synonym.bitkitcore.usdtFormatAmount import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch @@ -32,8 +33,12 @@ import to.bitkit.domain.commands.NotifyPendingPaymentResolved import to.bitkit.domain.commands.NotifyPendingPaymentResolvedHandler import to.bitkit.ext.activityManager import to.bitkit.models.NewTransactionSheetDetails +import to.bitkit.models.NewTransactionSheetDirection +import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.NotificationDetails import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.SparkRepo +import to.bitkit.repositories.SparkUsdTransfer import to.bitkit.repositories.WalletRepo import to.bitkit.services.NodeEventHandler import to.bitkit.services.NodeServiceFgState @@ -79,6 +84,9 @@ class LightningNodeService : Service() { @Inject lateinit var nodeServiceFgState: NodeServiceFgState + @Inject + lateinit var sparkRepo: SparkRepo + private var hasStartedNode = false private val nodeEventHandler: NodeEventHandler = { event -> @@ -91,6 +99,7 @@ class LightningNodeService : Service() { private fun setupService() { if (hasStartedNode) return hasStartedNode = true + serviceScope.launch { sparkRepo.usdReceived.collect { notifySparkUsdReceived(it) } } serviceScope.launch { lightningRepo.start( @@ -144,6 +153,18 @@ class LightningNodeService : Service() { pushNotification(notification.title, notification.body) } + private suspend fun notifySparkUsdReceived(transfer: SparkUsdTransfer) { + if (App.currentActivity?.value != null) return + val sheet = NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + usdtAmount = transfer.amount, + sparkUsdTransferId = transfer.id, + ) + cacheStore.setBackgroundReceive(sheet) + pushNotification(getString(R.string.usdt__received_title_spark), "$ " + usdtFormatAmount(transfer.amount)) + } + private suspend fun handlePendingPaymentResolved(event: Event) { val command = NotifyPendingPaymentResolved.Command.from(event) ?: return diff --git a/app/src/main/java/to/bitkit/domain/commands/ReceivedNotificationContent.kt b/app/src/main/java/to/bitkit/domain/commands/ReceivedNotificationContent.kt index 822d6a54a5..1b1ea6c721 100644 --- a/app/src/main/java/to/bitkit/domain/commands/ReceivedNotificationContent.kt +++ b/app/src/main/java/to/bitkit/domain/commands/ReceivedNotificationContent.kt @@ -16,8 +16,7 @@ import javax.inject.Singleton /** * Builds the user-facing "Payment Received" notification content, applying the same currency - * formatting rules everywhere a received payment is surfaced (foreground service, in-app handler, - * and the background [to.bitkit.fcm.WakeNodeWorker] push path). + * formatting rules everywhere a received payment is surfaced (foreground service and in-app handler). */ @Singleton class ReceivedNotificationContent @Inject constructor( diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 09797720d0..3bea2b3e82 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -4,13 +4,9 @@ import android.os.Build import com.synonym.bitkitcore.UsdtDestination import org.lightningdevkit.ldknode.LogLevel import org.lightningdevkit.ldknode.Network -import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.BuildConfig import to.bitkit.BuildConfig.VERSION_NAME import to.bitkit.ext.ensureDir -import to.bitkit.ext.of -import to.bitkit.models.BlocktankNotificationType -import to.bitkit.models.NodePeer import to.bitkit.utils.Logger import java.io.File import kotlin.io.path.Path @@ -35,6 +31,29 @@ internal object Env { ).filter { it.first in enabled }.map { it.second } } + val breezApiKey: String? get() = BuildConfig.BREEZ_API_KEY.takeIf { it.isNotBlank() } + + /** + * The Spark token held as the dollar balance on Spark, matched by identifier since anyone can issue a token + * with any ticker. Mainnet holds USDB, Brale's 6-decimal dollar token on Spark; regtest holds a 6-decimal + * "Test USDB" token issued on Lightspark's regtest. + */ + /** Orchestra, which bridges Spark dollars to Arbitrum USDT, serves mainnet only. */ + val isSparkBridgeAvailable: Boolean get() = network == Network.BITCOIN + + val sparkUsdTokenId: String? + get() = when (network) { + Network.REGTEST -> "btknrt1wrr0qtshpyuzrx5p0mvd605dnwvfv3dcpfexqgfzpgl3rykdacsq6p8z6l" + Network.BITCOIN -> "btkn1xgrvjwey5ngcagvap2dzzvsy4uk8ua9x69k82dwvt5e7ef9drm9qztux87" + else -> null + } + + /** + * Esplora endpoint for the node's chain source on regtest builds, set to put the wallet on Spark's regtest chain + * through `scripts/spark-regtest-esplora.py`; Electrum is used when unset. + */ + val regtestEsploraUrl: String? + get() = BuildConfig.ESPLORA_URL.takeIf { it.isNotBlank() && network == Network.REGTEST } val isDebug = BuildConfig.DEBUG const val isE2eTest = BuildConfig.E2E const val isGeoblockingEnabled = BuildConfig.GEO @@ -52,14 +71,6 @@ internal object Env { val ldkLogLevel = LogLevel.TRACE - val trustedLnPeers - get() = when (network) { - Network.BITCOIN -> listOf(Peers.lnd1, Peers.lnd3, Peers.lnd4) - Network.REGTEST -> listOf(Peers.stag) - Network.TESTNET -> listOf(Peers.stag) - else -> listOf() - } - /** * Whether LN -> onchain swaps can reach a Boltz backend. Boltz only serves a public API on * mainnet: its testnet deployment is deprecated and regtest resolves to a local backend that @@ -71,9 +82,6 @@ internal object Env { const val fxRateStaleThreshold = 10 * 60 * 1000L // 10 minutes in millis const val lspOrdersRefreshInterval = 2 * 60 * 1000L // 2 minutes in millis - val pushNotificationFeatures = BlocktankNotificationType.entries - const val derivationName = "bitkit-notifications" - const val FILE_PROVIDER_AUTHORITY = "${BuildConfig.APPLICATION_ID}.fileprovider" const val SUPPORT_EMAIL = "support@synonym.to" const val PIN_LENGTH = 4 @@ -140,11 +148,6 @@ internal object Env { Network.BITCOIN -> "$blocktankBaseUrl/api" else -> "$blocktankBaseUrl/blocktank/api/v2" } - val blocktankNotificationApiUrl - get() = when (network) { - Network.BITCOIN -> "$blocktankBaseUrl/api/notifications" - else -> "$blocktankBaseUrl/notifications/api" - } val btcRatesServer get() = when (network) { @@ -177,6 +180,7 @@ internal object Env { const val BITKIT_TELEGRAM = "https://t.me/bitkitchat" const val BITKIT_GITHUB = "https://github.com/synonymdev" const val BITKIT_HELP_CENTER = "https://help.bitkit.to" + const val BLOCKTANK_SUNSET_URL = "https://blog.bitkit.to/" const val TERMS_OF_USE_URL = "https://bitkit.to/terms-of-use" const val PRIVACY_POLICY_URL = "https://bitkit.to/privacy-policy" const val STORING_BITCOINS_URL = "https://en.bitcoin.it/wiki/Storing_bitcoins" @@ -238,6 +242,12 @@ internal object Env { fun ldkStoragePath(walletIndex: Int) = storagePathOf(walletIndex, network.name.lowercase(), "ldk") + fun sparkStoragePath(walletIndex: Int) = storagePathOf(walletIndex, network.name.lowercase(), "spark") + + /** A restored Spark exit data backup waiting to be imported once Spark connects; outside Spark's storage. */ + fun sparkExitRestorePath(walletIndex: Int) = + Path(storagePathOf(walletIndex, network.name.lowercase(), "spark-restore"), "exit-state.json").toString() + fun bitkitCoreStoragePath(walletIndex: Int): String { return storagePathOf(walletIndex, network.name.lowercase(), "core") } @@ -283,28 +293,6 @@ object Defaults { const val TAG_MAX_LENGTH = 20 } -object Peers { - val stag = PeerDetails.of("028a8910b0048630d4eb17af25668cdd7ea6f2d8ae20956e7a06e2ae46ebcb69fc@34.65.86.104:9400") - val lnd1 = PeerDetails.of("039b8b4dd1d88c2c5db374290cda397a8f5d79f312d6ea5d5bfdfc7c6ff363eae3@34.65.111.104:9735") - val lnd3 = PeerDetails.of("03816141f1dce7782ec32b66a300783b1d436b19777e7c686ed00115bd4b88ff4b@34.65.191.64:9735") - val lnd4 = PeerDetails.of("02a371038863605300d0b3fc9de0cf5ccb57728b7f8906535709a831b16e311187@34.65.153.174:9735") - - object Known { - val stag = NodePeer(Peers.stag, name = "Synonym-Own-Regtest-0") - val lnd1 = NodePeer(Peers.lnd1, name = "Blocktank-LND1") - val lnd3 = NodePeer(Peers.lnd3, name = "Blocktank-LND3") - val lnd4 = NodePeer(Peers.lnd4, name = "Blocktank-LND4") - - fun find(peer: PeerDetails): NodePeer? = when (peer.nodeId) { - stag.peerDetails.nodeId -> stag - lnd1.peerDetails.nodeId -> lnd1 - lnd3.peerDetails.nodeId -> lnd3 - lnd4.peerDetails.nodeId -> lnd4 - else -> null - } - } -} - private object ElectrumServers { object MAINNET { const val ESPLORA = "ssl://bitkit.to:9999" diff --git a/app/src/main/java/to/bitkit/ext/LightningBalance.kt b/app/src/main/java/to/bitkit/ext/LightningBalance.kt index 542f3248ee..e4019b5d26 100644 --- a/app/src/main/java/to/bitkit/ext/LightningBalance.kt +++ b/app/src/main/java/to/bitkit/ext/LightningBalance.kt @@ -24,6 +24,17 @@ fun LightningBalance.channelId(): String { } } +fun LightningBalance.counterpartyNodeId(): String { + return when (this) { + is LightningBalance.ClaimableOnChannelClose -> this.counterpartyNodeId + is LightningBalance.ClaimableAwaitingConfirmations -> this.counterpartyNodeId + is LightningBalance.ContentiousClaimable -> this.counterpartyNodeId + is LightningBalance.MaybeTimeoutClaimableHtlc -> this.counterpartyNodeId + is LightningBalance.MaybePreimageClaimableHtlc -> this.counterpartyNodeId + is LightningBalance.CounterpartyRevokedOutputClaimable -> this.counterpartyNodeId + } +} + fun LightningBalance.claimableAtHeight(): UInt? = when (this) { is LightningBalance.ClaimableAwaitingConfirmations -> this.confirmationHeight is LightningBalance.ContentiousClaimable -> this.timeoutHeight diff --git a/app/src/main/java/to/bitkit/fcm/FcmService.kt b/app/src/main/java/to/bitkit/fcm/FcmService.kt index b37a7e655a..e8441718ab 100644 --- a/app/src/main/java/to/bitkit/fcm/FcmService.kt +++ b/app/src/main/java/to/bitkit/fcm/FcmService.kt @@ -2,28 +2,12 @@ package to.bitkit.fcm import android.os.Bundle import androidx.core.os.toPersistableBundle -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.OutOfQuotaPolicy -import androidx.work.WorkManager -import androidx.work.workDataOf import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import dagger.hilt.android.AndroidEntryPoint -import kotlinx.serialization.Serializable -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.JsonObject -import to.bitkit.data.keychain.Keychain -import to.bitkit.di.json -import to.bitkit.env.Env.derivationName -import to.bitkit.ext.fromBase64 -import to.bitkit.ext.fromHex -import to.bitkit.models.BlocktankNotificationType -import to.bitkit.repositories.LightningRepo import to.bitkit.ui.pushNotification -import to.bitkit.utils.Crypto import to.bitkit.utils.Logger import java.util.Date -import javax.inject.Inject @AndroidEntryPoint class FcmService : FirebaseMessagingService() { @@ -31,18 +15,6 @@ class FcmService : FirebaseMessagingService() { private const val TAG = "FcmService" } - private var notificationType: BlocktankNotificationType? = null - private var notificationPayload: JsonObject? = null - - @Inject - lateinit var crypto: Crypto - - @Inject - lateinit var keychain: Keychain - - @Inject - lateinit var lightningRepo: LightningRepo - /** * Act on received messages. [Debug](https://goo.gl/39bRNJ) */ @@ -54,123 +26,13 @@ class FcmService : FirebaseMessagingService() { Logger.debug("FCM body: $body", context = TAG) sendNotification(title, body, Bundle(message.data.toPersistableBundle())) } - - if (message.data.isNotEmpty()) { - Logger.debug("FCM data: ${message.data}", context = TAG) - - val shouldSchedule = runCatching { - val isEncryptedNotification = message.data.tryAs { - decryptPayload(it) - } - isEncryptedNotification - }.getOrElse { - Logger.error("Failed to read encrypted notification payload", it, context = TAG) - // Let the node to spin up and handle incoming events - true - } - - when (shouldSchedule) { - true -> handleAsync() - else -> handleNow(message.data) - } - } - } - - private fun handleAsync() { - val work = OneTimeWorkRequestBuilder() - .setInputData( - workDataOf( - "type" to notificationType?.name, - "payload" to notificationPayload?.toString(), - ) - ) - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - .build() - WorkManager.getInstance(this) - .beginWith(work) - .enqueue() - } - - private fun handleNow(data: Map) { - Logger.warn("FCM handler not implemented for: $data", context = TAG) - } - - private fun decryptPayload(response: EncryptedNotification) { - val ciphertext = runCatching { response.cipher.fromBase64() }.getOrElse { - Logger.error("Failed to decode cipher", it, context = TAG) - return - } - val privateKey = runCatching { keychain.load(Keychain.Key.PUSH_NOTIFICATION_PRIVATE_KEY.name)!! }.getOrElse { - Logger.error("Missing PUSH_NOTIFICATION_PRIVATE_KEY", it, context = TAG) - return - } - val password = - runCatching { crypto.generateSharedSecret(privateKey, response.publicKey, derivationName) }.getOrElse { - Logger.error("Failed to generate shared secret", it, context = TAG) - return - } - - val decrypted = crypto.decrypt( - encryptedPayload = Crypto.EncryptedPayload(ciphertext, response.iv.fromHex(), response.tag.fromHex()), - secretKey = password, - ) - - val decoded = decrypted.decodeToString() - Logger.debug("Decrypted payload: $decoded", context = TAG) - - val (payload, type) = runCatching { json.decodeFromString(decoded) }.getOrElse { - Logger.error("Failed to decode decrypted data", it, context = TAG) - return - } - - if (payload == null) { - Logger.error("Missing payload", context = TAG) - return - } - - if (type == null) { - Logger.error("Missing type", context = TAG) - return - } - - notificationType = type - notificationPayload = payload } private fun sendNotification(title: String?, body: String?, extras: Bundle? = null) { applicationContext.pushNotification(title, body, extras) } - private inline fun Map.tryAs(block: (T) -> Unit): Boolean { - val encoded = json.encodeToString(this) - return try { - val decoded = json.decodeFromString(encoded) - block(decoded) - true - } catch (_: SerializationException) { - false - } - } - override fun onNewToken(token: String) { Logger.debug("Got new FCM token via FirebaseMessagingService.onNewToken: '$token'", context = TAG) - lightningRepo.registerForNotificationsAsync(token) } } - -@Serializable -data class EncryptedNotification( - val cipher: String, - val iv: String, - val tag: String, - val sound: String = "", - val title: String = "", - val message: String = "", - val publicKey: String = "", -) - -@Serializable -data class DecryptedNotification( - val payload: JsonObject? = null, - val type: BlocktankNotificationType? = null, -) diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt deleted file mode 100644 index 8edf0af18e..0000000000 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ /dev/null @@ -1,298 +0,0 @@ -package to.bitkit.fcm - -import android.content.Context -import androidx.hilt.work.HiltWorker -import androidx.work.CoroutineWorker -import androidx.work.WorkerParameters -import androidx.work.workDataOf -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.delay -import kotlinx.coroutines.withTimeout -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonObject -import org.lightningdevkit.ldknode.Event -import to.bitkit.App -import to.bitkit.R -import to.bitkit.data.CacheStore -import to.bitkit.di.json -import to.bitkit.domain.commands.ReceivedNotificationContent -import to.bitkit.ext.amountOnClose -import to.bitkit.ext.toUserMessage -import to.bitkit.models.BlocktankNotificationType -import to.bitkit.models.BlocktankNotificationType.cjitPaymentArrived -import to.bitkit.models.BlocktankNotificationType.incomingHtlc -import to.bitkit.models.BlocktankNotificationType.mutualClose -import to.bitkit.models.BlocktankNotificationType.orderPaymentConfirmed -import to.bitkit.models.BlocktankNotificationType.wakeToTimeout -import to.bitkit.models.NewTransactionSheetDetails -import to.bitkit.models.NewTransactionSheetDirection -import to.bitkit.models.NewTransactionSheetType -import to.bitkit.models.NotificationDetails -import to.bitkit.models.msatCeilOf -import to.bitkit.repositories.ActivityRepo -import to.bitkit.repositories.BlocktankRepo -import to.bitkit.repositories.LightningRepo -import to.bitkit.services.NodeServiceFgState -import to.bitkit.ui.pushNotification -import to.bitkit.utils.Logger -import to.bitkit.utils.measured -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds - -@HiltWorker -class WakeNodeWorker @AssistedInject constructor( - @Assisted private val appContext: Context, - @Assisted private val workerParams: WorkerParameters, - private val lightningRepo: LightningRepo, - private val blocktankRepo: BlocktankRepo, - private val activityRepo: ActivityRepo, - private val cacheStore: CacheStore, - private val receivedNotificationContent: ReceivedNotificationContent, - private val nodeServiceFgState: NodeServiceFgState, -) : CoroutineWorker(appContext, workerParams) { - private var bestAttemptContent: NotificationDetails? = null - - private var notificationType: BlocktankNotificationType? = null - private var notificationPayload: JsonObject? = null - - private val timeout = 2.minutes - private val slowWakeNodeThreshold = 10.seconds - private val deliverSignal = CompletableDeferred() - - override suspend fun doWork(): Result { - Logger.debug("Node wakeup from notification…", context = TAG) - - notificationType = workerParams.inputData.getString("type")?.let { BlocktankNotificationType.valueOf(it) } - notificationPayload = workerParams.inputData.getString("payload")?.let { - runCatching { json.parseToJsonElement(it).jsonObject }.getOrNull() - } - - Logger.debug("notification type: $notificationType", context = TAG) - Logger.debug("notification payload: $notificationPayload", context = TAG) - - if (notificationType == null) { - Logger.warn("Notification type is null, proceeding with node wake", context = TAG) - } - - return runCatching { - measured(label = "doWork", context = TAG, slowThreshold = slowWakeNodeThreshold) { - lightningRepo.start( - walletIndex = 0, - timeout = timeout, - eventHandler = { event -> handleLdkEvent(event) } - ) - - // Once node is started, handle the manual channel opening if needed - if (notificationType == orderPaymentConfirmed) { - val orderId = (notificationPayload?.get("orderId") as? JsonPrimitive)?.contentOrNull - - if (orderId == null) { - Logger.error("Missing orderId", context = TAG) - } else { - Logger.info("Open channel request for order $orderId", context = TAG) - blocktankRepo.openChannel(orderId).onFailure { - Logger.error("Failed to open channel", it, context = TAG) - bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification__channel_open_failed_title), - body = it.message ?: appContext.getString(R.string.common__error_body), - ) - deliver() - } - } - } - } - // Stops node on timeout & avoids notification replay by OS - withTimeout(timeout) { deliverSignal.await() } - } - .fold( - onSuccess = { Result.success() }, - onFailure = { e -> - if (e is CancellationException) { - Logger.debug("Work cancelled", context = TAG) - return@fold Result.failure(workDataOf("Reason" to "Cancelled")) - } - - val reason = e.message ?: appContext.getString(R.string.common__error_body) - - bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification__lightning_error_title), - body = reason, - ) - Logger.error("Lightning error", e, context = TAG) - deliver() - - Result.failure(workDataOf("Reason" to reason)) - } - ) - } - - /** - * Listens for LDK events and delivers the notification if the event matches the notification type. - * @param event The LDK event to check. - */ - private suspend fun handleLdkEvent(event: Event) { - when (event) { - is Event.PaymentReceived -> onPaymentReceived(event) - - is Event.ChannelPending -> { - bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification__channel_opened_title), - body = appContext.getString(R.string.notification__channel_pending_body), - ) - // Don't deliver, give a chance for channelReady event to update the content if it's a turbo channel - } - - is Event.ChannelReady -> onChannelReady(event) - is Event.ChannelClosed -> onChannelClosed(event) - - is Event.PaymentFailed -> { - bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification__payment_failed_title), - body = "⚡ ${event.reason.toUserMessage(appContext)}", - ) - - if (notificationType == wakeToTimeout) { - deliver() - } - } - - else -> Unit - } - } - - private suspend fun onChannelClosed(event: Event.ChannelClosed) { - bestAttemptContent = when (notificationType) { - mutualClose -> NotificationDetails( - title = appContext.getString(R.string.notification__channel_closed__title), - body = appContext.getString(R.string.notification__channel_closed__mutual_body), - ) - - orderPaymentConfirmed -> NotificationDetails( - title = appContext.getString(R.string.notification__channel_open_bg_failed_title), - body = appContext.getString(R.string.notification__please_try_again_body), - ) - - else -> NotificationDetails( - title = appContext.getString(R.string.notification__channel_closed__title), - body = appContext.getString(R.string.notification__channel_closed__reason_body, event.reason), - ) - } - - deliver() - } - - private suspend fun onPaymentReceived(event: Event.PaymentReceived) { - val sats = msatCeilOf(event.amountMsat) - // Save for UI to pick up - cacheStore.setBackgroundReceive( - NewTransactionSheetDetails( - type = NewTransactionSheetType.LIGHTNING, - direction = NewTransactionSheetDirection.RECEIVED, - paymentHashOrTxId = event.paymentHash, - sats = sats.toLong(), - ) - ) - - // The in-app UI or foreground service shows a richer notification for this event; avoid duplicating it - if (isHandledInProcess()) { - Logger.debug("Skipping payment notification: handled in-process", context = TAG) - bestAttemptContent = null - } else { - bestAttemptContent = receivedNotificationContent.build(sats.toLong()) - } - - if (notificationType == incomingHtlc) { - deliver() - } - } - - private suspend fun onChannelReady(event: Event.ChannelReady) { - when (notificationType) { - cjitPaymentArrived -> onCjitChannelReady(event) - - orderPaymentConfirmed -> bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification__channel_opened_title), - body = appContext.getString(R.string.notification__channel_ready_body), - ) - - else -> Unit - } - deliver() - } - - private suspend fun onCjitChannelReady(event: Event.ChannelReady) { - val channel = lightningRepo.getChannels()?.find { it.channelId == event.channelId } - val cjitEntry = channel?.let { blocktankRepo.getCjitEntry(it) } - - // A regular (non-CJIT) channel opening must not be reported as a received payment - if (channel == null || cjitEntry == null) { - Logger.debug("Skipping CJIT notification: no cjit entry for channel '${event.channelId}'", context = TAG) - bestAttemptContent = null - return - } - - // The in-app UI or foreground service owns this event: it records the activity (via the same - // insertActivityFromCjit dedup) and shows the richer notification/sheet. Defer entirely — - // do NOT insert here, or we'd win the dedup race and silence the in-process handler. - if (isHandledInProcess()) { - Logger.debug("Skipping CJIT notification: handled in-process", context = TAG) - bestAttemptContent = null - return - } - - // A duplicate channel ready event for an already recorded CJIT receive must not notify again - val inserted = activityRepo.insertActivityFromCjit(cjitEntry = cjitEntry, channel = channel) - .getOrDefault(false) - if (!inserted) { - Logger.debug("Skipping CJIT notification: already recorded for '${event.channelId}'", context = TAG) - bestAttemptContent = null - return - } - - val sats = channel.amountOnClose - // Save for UI to pick up - cacheStore.setBackgroundReceive( - NewTransactionSheetDetails( - type = NewTransactionSheetType.LIGHTNING, - direction = NewTransactionSheetDirection.RECEIVED, - sats = sats.toLong(), - ) - ) - - bestAttemptContent = receivedNotificationContent.build(sats.toLong()) - } - - private fun isHandledInProcess(): Boolean = - App.currentActivity?.value != null || nodeServiceFgState.isForegroundServiceRunning - - private suspend fun deliver() { - // Send notification first - bestAttemptContent?.run { - appContext.pushNotification(title, body) - Logger.info("Delivered notification", context = TAG) - } - - // Delay briefly to allow app to come to foreground if user clicked notification - delay(1.seconds) - - // Only stop node if app is not in foreground - // LightningNodeService will keep node running in background when notifications are enabled - if (App.currentActivity?.value == null) { - Logger.debug("App in background, stopping node after notification delivery", context = TAG) - lightningRepo.stop() - } else { - Logger.debug("App in foreground, keeping node running", context = TAG) - } - - deliverSignal.complete(Unit) - } - - companion object { - private const val TAG = "WakeNodeWorker" - } -} diff --git a/app/src/main/java/to/bitkit/models/BackupCategory.kt b/app/src/main/java/to/bitkit/models/BackupCategory.kt index 0181b711e7..6b5df079bd 100644 --- a/app/src/main/java/to/bitkit/models/BackupCategory.kt +++ b/app/src/main/java/to/bitkit/models/BackupCategory.kt @@ -38,6 +38,10 @@ enum class BackupCategory( icon = R.drawable.ic_tag, title = R.string.settings__backup__category_tags, ), + SPARK_EXIT( + icon = R.drawable.ic_lightning, + title = R.string.settings__backup__category_spark_exit, + ), // Descoped in v1, will return in v2: // PROFILE( // icon = R.drawable.ic_user, diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt index c52781198c..5c150a5eb1 100644 --- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -86,3 +86,15 @@ data class WidgetsBackupV1( val createdAt: Long, val widgets: WidgetsData, ) + +/** + * The Spark wallet's unilateral exit data, as the SDK exports it, kept so a wallet restored on another device can + * still exit on-chain while the Spark operators are unreachable. + */ +@Serializable +data class SparkExitBackupV1( + val version: Int = 1, + val createdAt: Long, + val network: String, + val exitState: String, +) diff --git a/app/src/main/java/to/bitkit/models/BlocktankNotificationType.kt b/app/src/main/java/to/bitkit/models/BlocktankNotificationType.kt deleted file mode 100644 index abc02cb537..0000000000 --- a/app/src/main/java/to/bitkit/models/BlocktankNotificationType.kt +++ /dev/null @@ -1,15 +0,0 @@ -package to.bitkit.models - -import kotlinx.serialization.Serializable - -@Suppress("EnumEntryNameCase", "EnumNaming") -@Serializable -enum class BlocktankNotificationType { - incomingHtlc, - mutualClose, - orderPaymentConfirmed, - cjitPaymentArrived, - wakeToTimeout; - - override fun toString(): String = "blocktank.$name" -} diff --git a/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt b/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt index 3963b4b524..d7595d448a 100644 --- a/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt +++ b/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt @@ -14,6 +14,8 @@ data class NewTransactionSheetDetails( val sats: Long = 0, val usdtAmount: ULong? = null, val usdtTransferId: String? = null, + /** A Spark dollar activity, shown with [usdtAmount] as its dollar amount. */ + val sparkUsdTransferId: String? = null, val isLoadingDetails: Boolean = false, ) { companion object { diff --git a/app/src/main/java/to/bitkit/models/NodePeer.kt b/app/src/main/java/to/bitkit/models/NodePeer.kt index 584c54ecf3..ba0041844f 100644 --- a/app/src/main/java/to/bitkit/models/NodePeer.kt +++ b/app/src/main/java/to/bitkit/models/NodePeer.kt @@ -7,10 +7,8 @@ import to.bitkit.ext.ellipsisMiddle data class NodePeer( val peerDetails: PeerDetails, val lspNode: ILspNode? = null, - val name: String? = null, ) fun NodePeer.alias(): String = lspNode?.alias - ?: name ?: peerDetails.nodeId.ellipsisMiddle(16) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index cf99346ba0..076b8bcb41 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -18,8 +18,10 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -62,6 +64,7 @@ import to.bitkit.models.BlocktankBackupV1 import to.bitkit.models.MetadataBackupV1 import to.bitkit.models.PaykitPaymentStateBackup import to.bitkit.models.SettingsBackupV1 +import to.bitkit.models.SparkExitBackupV1 import to.bitkit.models.Toast import to.bitkit.models.WalletBackupV1 import to.bitkit.models.WalletScope @@ -128,6 +131,7 @@ class BackupRepo @Inject constructor( private val onchainSendAttemptStore: OnchainSendAttemptStore, private val transferRepo: Provider, private val lightningRepo: Provider, + private val sparkRepo: SparkRepo, private val vssStoreIdProvider: VssStoreIdProvider, private val preActivityMetadataRepo: PreActivityMetadataRepo, private val lightningService: LightningService, @@ -405,6 +409,20 @@ class BackupRepo @Inject constructor( // payment and sync traffic does not re-upload the whole metadata envelope. dataListenerJobs.add(observeBackupChanges(activityRepo.activityTagsChanged, BackupCategory.METADATA)) + // SPARK_EXIT - back up once Spark is ready, which replays when observing starts after it connected, + // and again whenever Spark announces its unilateral exit data changed + dataListenerJobs.add( + scope.launch { + merge( + sparkRepo.state.map { it.isReady }.distinctUntilChanged().filter { it }, + sparkRepo.exitStateChanged, + ).collect { + if (shouldSkipBackup()) return@collect + markBackupRequired(BackupCategory.SPARK_EXIT) + } + } + ) + // LIGHTNING_CONNECTIONS - Only display sync timestamp, ldk-node manages its own backups @OptIn(FlowPreview::class) val lightningConnectionsJob = scope.launch { @@ -543,6 +561,8 @@ class BackupRepo @Inject constructor( val upload = if (category == BackupCategory.WALLET) { uploadWalletBackup(null) + } else if (category == BackupCategory.SPARK_EXIT) { + uploadSparkExitBackup() } else { runSuspendCatching { vssBackupClient.putObject(key = category.name, data = getBackupDataBytes(category)).getOrThrow() @@ -572,6 +592,15 @@ class BackupRepo @Inject constructor( } } + /** Nothing to back up until Spark connects, which marks [BackupCategory.SPARK_EXIT] required again. */ + private suspend fun uploadSparkExitBackup(): Result = runSuspendCatching { + val backup = sparkRepo.exitBackup().getOrThrow() ?: return@runSuspendCatching + vssBackupClient.putObject( + key = BackupCategory.SPARK_EXIT.name, + data = json.encodeToString(backup).toByteArray(), + ).getOrThrow() + } + suspend fun persistWalletBackup(usdt: String? = null): Result = uploadWalletBackup(usdt).onFailure { markBackupRequired(BackupCategory.WALLET) } @@ -672,6 +701,11 @@ class BackupRepo @Inject constructor( } BackupCategory.LIGHTNING_CONNECTIONS -> throw NotImplementedError("LIGHTNING backup is managed by ldk-node") + + BackupCategory.SPARK_EXIT -> { + val backup = checkNotNull(sparkRepo.exitBackup().getOrThrow()) { "Spark is not connected" } + json.encodeToString(backup).toByteArray() + } } private suspend fun getMetadataBackupDataBytes(): ByteArray = withContext(ioDispatcher) { @@ -816,6 +850,12 @@ class BackupRepo @Inject constructor( widgetsStore.restoreFromBackup(parsed) parsed.createdAt } + // Before the slower categories, so the exit data is staged before Spark connects and syncs. + performRestore(BackupCategory.SPARK_EXIT) { dataBytes -> + val parsed = json.decodeFromString(String(dataBytes)) + sparkRepo.restoreExitBackup(parsed, backupWalletIndex()).getOrThrow() + parsed.createdAt + } val retainedWalletBackup = keychain.loadString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name) if (retainedWalletBackup.isNullOrEmpty()) { var didRestoreWalletBackup = false diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index d1752edce9..0b5d94a386 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1,7 +1,6 @@ package to.bitkit.repositories import androidx.compose.runtime.Stable -import com.google.firebase.messaging.FirebaseMessaging import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.ClosedChannelDetails import com.synonym.bitkitcore.FeeRates @@ -37,7 +36,6 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.JsonPrimitive @@ -50,6 +48,7 @@ import org.lightningdevkit.ldknode.ChannelDataMigration import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.ClosureReason import org.lightningdevkit.ldknode.CoinSelectionAlgorithm +import org.lightningdevkit.ldknode.ConfirmationStatus import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.NodeStatus @@ -57,6 +56,7 @@ import org.lightningdevkit.ldknode.OutPoint import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentHash import org.lightningdevkit.ldknode.PaymentId +import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PeerDetails import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.Txid @@ -69,10 +69,10 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Defaults import to.bitkit.env.Env +import to.bitkit.ext.calculateRemoteBalance import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.nowMillis import to.bitkit.ext.runSuspendCatching -import to.bitkit.ext.toPeerDetailsList import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS import to.bitkit.models.CoinSelectionPreference @@ -81,6 +81,7 @@ import to.bitkit.models.ElectrumServer import to.bitkit.models.NATIVE_WITNESS_TYPES import to.bitkit.models.NodeLifecycleState import to.bitkit.models.OpenChannelResult +import to.bitkit.models.ReceiveLiquidityDecision import to.bitkit.models.TransactionSpeed import to.bitkit.models.WalletScope import to.bitkit.models.safe @@ -96,7 +97,6 @@ import to.bitkit.services.LightningService import to.bitkit.services.LnurlChannelResponse import to.bitkit.services.LnurlService import to.bitkit.services.LnurlWithdrawResponse -import to.bitkit.services.LspNotificationsService import to.bitkit.services.NetworkGraphInfo import to.bitkit.services.NodeEventHandler import to.bitkit.utils.AppError @@ -123,8 +123,6 @@ class LightningRepo @Inject constructor( private val lightningService: LightningService, private val settingsStore: SettingsStore, private val coreService: CoreService, - private val lspNotificationsService: LspNotificationsService, - private val firebaseMessaging: FirebaseMessaging, private val keychain: Keychain, private val lnurlService: LnurlService, private val cacheStore: CacheStore, @@ -134,6 +132,7 @@ class LightningRepo @Inject constructor( private val vssBackupClientLdk: VssBackupClientLdk, private val urlValidator: UrlValidator, private val electrumProbeService: ElectrumProbeService, + private val sparkRepo: SparkRepo, ) : OnchainReleasing { private val _lightningState = MutableStateFlow(LightningState()) val lightningState = _lightningState.asStateFlow() @@ -179,6 +178,7 @@ class LightningRepo @Inject constructor( init { observeConnectivityForSyncRetry() + scope.launch { sparkRepo.nodeEvents.collect { onSparkEvent(it) } } } private fun observeConnectivityForSyncRetry() { @@ -194,7 +194,6 @@ class LightningRepo @Inject constructor( } if (_lightningState.value.nodeLifecycleState.isRunning()) { - connectToTrustedPeers() scheduleOrdinaryFollowupRetry() } @@ -306,12 +305,10 @@ class LightningRepo @Inject constructor( channelMigration: ChannelDataMigration? = null, ) = withContext(bgDispatcher) { runSuspendCatching { - val trustedPeers = fetchTrustedPeers() lightningService.setup( walletIndex, customServerUrl, customRgsServerUrl, - trustedPeers, channelMigration, ) }.onFailure { @@ -319,16 +316,6 @@ class LightningRepo @Inject constructor( } } - private suspend fun fetchTrustedPeers(): List? = runSuspendCatching { - val info = coreService.blocktank.info(refresh = false) - ?: coreService.blocktank.info(refresh = true) - info?.nodes?.toPeerDetailsList()?.also { - Logger.info("Fetched ${it.size} trusted peers from remote", context = TAG) - } - }.onFailure { - Logger.warn("fetchTrustedPeers error", it, context = TAG) - }.getOrNull() - suspend fun start( walletIndex: Int = 0, timeout: Duration? = null, @@ -368,6 +355,12 @@ class LightningRepo @Inject constructor( if (shouldCancelPendingStop) cancelPendingStop() + // The geocheck needs no node, so screens gated on it are right before the node is up. + scope.launch { updateGeoBlockState() } + + // Spark needs no node, so it starts alongside it and stays up while the node stops in background. + scope.launch { sparkRepo.start(walletIndex) } + eventHandler?.let { _eventHandlers.add(it) } // Track retry state outside mutex to avoid deadlock (Mutex is non-reentrant) @@ -436,15 +429,14 @@ class LightningRepo @Inject constructor( } // Post-startup tasks (non-blocking) - connectToTrustedPeers().onFailure { - Logger.error("Failed to connect to trusted peers", it, context = TAG) + disconnectIdlePeers().onFailure { + Logger.error("Failed to disconnect idle peers", it, context = TAG) } - logNodeSupportSummary("trusted peers connected") + logNodeSupportSummary("idle peers disconnected") sync().onFailure { e -> Logger.warn("Initial sync failed, event-driven sync will retry", e, context = TAG) } - scope.launch { registerForNotifications() } Result.success(Unit) }.getOrElse { e -> if (e is CancellationException) { @@ -618,6 +610,14 @@ class LightningRepo @Inject constructor( ) } + private suspend fun onSparkEvent(event: Event) { + val settledReceiveInvoice = (event as? Event.PaymentReceived)?.let { invalidateSettledReceiveInvoice(it) } + _eventHandlers.toList().forEach { + runCatching { it.invoke(event) } + } + _nodeEventUpdates.emit(NodeEventUpdate(event = event, settledReceiveInvoice = settledReceiveInvoice)) + } + private suspend fun invalidateSettledReceiveInvoice(event: Event.PaymentReceived): SettledReceiveInvoice? { val cachedReceive = runSuspendCatching { cacheStore.data.first() @@ -672,6 +672,13 @@ class LightningRepo @Inject constructor( fun setWiping(enabled: Boolean) = run { isWiping = enabled } + /** Blocks a transfer to spending unless the geocheck confirms the region is allowed. */ + suspend fun isTransferBlocked(): Boolean = withContext(bgDispatcher) { + coreService.isGeoBlocked(blockOnError = true).also { blocked -> + _lightningState.update { it.copy(isGeoBlocked = blocked) } + } + } + suspend fun updateGeoBlockState() = withContext(bgDispatcher) { _lightningState.update { it.copy(isGeoBlocked = coreService.isGeoBlocked()) @@ -726,7 +733,9 @@ class LightningRepo @Inject constructor( _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopping) } lightningService.stop() clearProbeOutcomes() - _lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } + _lightningState.update { + LightningState(nodeLifecycleState = NodeLifecycleState.Stopped, isGeoBlocked = it.isGeoBlocked) + } } }.onFailure { Logger.error("Node stop error", it, context = TAG) @@ -737,7 +746,9 @@ class LightningRepo @Inject constructor( _lightningState.update { s -> s.copy(nodeLifecycleState = NodeLifecycleState.Running) } } else { // Node appears stopped, update state - _lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } + _lightningState.update { + LightningState(nodeLifecycleState = NodeLifecycleState.Stopped, isGeoBlocked = it.isGeoBlocked) + } } } } @@ -919,6 +930,7 @@ class LightningRepo @Inject constructor( Logger.debug("node stopped, calling wipeStorage", context = TAG) lastKnownNodeId = null lightningService.wipeStorage(walletIndex) + sparkRepo.wipe(walletIndex) clearProbeOutcomes() _lightningState.update { LightningState( @@ -1255,8 +1267,8 @@ class LightningRepo @Inject constructor( return@withContext Result.success(Unit) } - suspend fun connectToTrustedPeers(): Result = executeWhenNodeRunning("connectToTrustedPeers") { - runCatching { lightningService.connectToTrustedPeers() }.also { + suspend fun disconnectIdlePeers(): Result = executeWhenNodeRunning("disconnectIdlePeers") { + runSuspendCatching { lightningService.disconnectIdlePeers() }.also { syncState() } } @@ -1313,6 +1325,9 @@ class LightningRepo @Inject constructor( expirySeconds: UInt = Defaults.bolt11ExpirySec, ): Result = executeWhenNodeRunning("createInvoice") { updateGeoBlockState() + if (shouldReceiveWithSpark(amountSats)) { + return@executeWhenNodeRunning sparkRepo.createInvoice(amountSats, description, expirySeconds) + } runCatching { lightningService.receive(amountSats, description, expirySeconds) } } @@ -1322,9 +1337,34 @@ class LightningRepo @Inject constructor( expirySeconds: UInt = Defaults.bolt11ExpirySec, ): Result = executeWhenNodeRunning("createInvoiceMsats") { updateGeoBlockState() + if (shouldReceiveWithSpark(amountMsats / MSATS_PER_SAT)) { + val amountSats = (amountMsats / MSATS_PER_SAT).takeIf { it > 0uL } + return@executeWhenNodeRunning sparkRepo.createInvoice(amountSats, description, expirySeconds) + } runCatching { lightningService.receiveMsats(amountMsats, description, expirySeconds) } } + /** Receives on Spark when no ready channel has the inbound capacity for [amountSats]. */ + private fun shouldReceiveWithSpark(amountSats: ULong?): Boolean { + if (!sparkRepo.canReceive()) return false + val readyChannels = _lightningState.value.channels.filter { it.isChannelReady } + return !ReceiveLiquidityDecision.canCreateLightningInvoice( + hasReadyChannels = readyChannels.isNotEmpty(), + inboundCapacitySats = readyChannels.calculateRemoteBalance(), + invoiceAmountSats = amountSats, + ) + } + + /** Pays with Spark when no channel has the outbound capacity for the invoice amount. */ + private fun shouldPayWithSpark(bolt11: String, sats: ULong?): Boolean { + val amountSats = sats ?: runCatching { Bolt11Invoice.fromStr(bolt11).amountMilliSatoshis() }.getOrNull() + ?.let { it / MSATS_PER_SAT } ?: return false + val state = _lightningState.value + val ldkCanSend = state.nodeLifecycleState.canRun() && + state.channels.totalNextOutboundHtlcLimitSats() >= amountSats + return !ldkCanSend && sparkRepo.canSend(amountSats) + } + suspend fun fetchLnurlInvoice( data: LnurlPayData, amountMsats: ULong, @@ -1416,6 +1456,20 @@ class LightningRepo @Inject constructor( paymentDeadlineAt: Instant?, maxTotalRoutingFeeMsat: ULong?, onBeforeSend: suspend () -> Boolean, + ): Result { + if (shouldPayWithSpark(bolt11, sats)) { + if (!onBeforeSend()) return Result.failure(PaymentAbortedBeforeSend()) + return sparkRepo.payInvoice(bolt11, sats) + } + return payInvoiceWithLdk(bolt11, sats, paymentDeadlineAt, maxTotalRoutingFeeMsat, onBeforeSend) + } + + private suspend fun payInvoiceWithLdk( + bolt11: String, + sats: ULong?, + paymentDeadlineAt: Instant?, + maxTotalRoutingFeeMsat: ULong?, + onBeforeSend: suspend () -> Boolean, ): Result = executeWhenNodeRunning("payInvoice") { waitForUsableChannels() if (!onBeforeSend()) return@executeWhenNodeRunning Result.failure(PaymentAbortedBeforeSend()) @@ -1424,6 +1478,17 @@ class LightningRepo @Inject constructor( } } + /** Confirmations of an on-chain transaction this wallet sent or received, 0 while unconfirmed. */ + suspend fun onchainConfirmations(txid: String): UInt? = withContext(bgDispatcher) { + val kind = lightningService.listPayments() + ?.map { it.kind } + ?.filterIsInstance() + ?.firstOrNull { it.txid.equals(txid, ignoreCase = true) } ?: return@withContext null + val confirmed = kind.status as? ConfirmationStatus.Confirmed ?: return@withContext 0u + val tip = _lightningState.value.block()?.height ?: return@withContext null + if (tip < confirmed.height) 0u else tip - confirmed.height + 1u + } + suspend fun listPaymentsOrNull(): List? = withContext(bgDispatcher) { lightningService.listPayments() } @@ -1869,9 +1934,10 @@ class LightningRepo @Inject constructor( } } - suspend fun completeAcceptedTransferFollowup(orderId: String, txid: String) { - val attempt = onchainSendAttemptStore.current() - if (attempt?.orderId == orderId && attempt.txid.equals(txid, ignoreCase = true) && + /** Finishes an accepted transfer locally; [orderId] is null for a transfer that funds the Spark wallet. */ + suspend fun completeAcceptedTransferFollowup(orderId: String?, txid: String) { + val attempt = onchainSendAttemptStore.current() ?: return + if (attempt.isTransfer && attempt.orderId == orderId && attempt.txid.equals(txid, ignoreCase = true) && attempt.hasPositiveEvidence ) { finishOnchainSendLocally(attempt) @@ -2197,6 +2263,7 @@ class LightningRepo @Inject constructor( fun canSend(amountSats: ULong): Boolean { val state = _lightningState.value + if (sparkRepo.canSend(amountSats)) return true if (!state.nodeLifecycleState.canRun()) return false return state.channels.totalNextOutboundHtlcLimitSats() >= amountSats } @@ -2242,39 +2309,18 @@ class LightningRepo @Inject constructor( fun getChannels(): List? = if (_lightningState.value.nodeLifecycleState.isRunning()) lightningService.channels else null + /** The completion event of a Spark send that settled before its caller started watching for it. */ + suspend fun settledSparkSendEvent(paymentHash: String): Event? = sparkRepo.settledSendEvent(paymentHash) + + /** + * Whether Lightning can be received, through a usable channel or Spark; Paykit publishes a Lightning endpoint + * only then. + */ fun canReceive(): Boolean { val isRunning = _lightningState.value.nodeLifecycleState.isRunning() - return isRunning && lightningService.canReceive() + return isRunning && (lightningService.canReceive() || sparkRepo.canReceive()) } - fun separateTrustedChannels(channels: List) = lightningService.separateTrustedChannels(channels) - - @Suppress("KotlinConstantConditions") - suspend fun registerForNotifications(token: String? = null) = executeWhenNodeRunning("registerForNotifications") { - if (Env.isE2eTest) { - Logger.info("Skipped push registration in E2E build", context = TAG) - return@executeWhenNodeRunning Result.success(Unit) - } - - runCatching { - val token = token ?: firebaseMessaging.token.await() - val cachedToken = keychain.loadString(Keychain.Key.PUSH_NOTIFICATION_TOKEN.name) - - require(token.isNotEmpty()) { "FCM token is empty or null" } - - if (cachedToken == token) { - Logger.debug("registerForNotifications skipped, device token already registered") - return@executeWhenNodeRunning Result.success(Unit) - } - - lspNotificationsService.registerDevice(token) - }.onFailure { - Logger.error("registerForNotifications error", it, context = TAG) - } - } - - fun registerForNotificationsAsync(token: String) = scope.launch { registerForNotifications(token) } - suspend fun bumpFeeByRbf( originalTxId: Txid, satsPerVByte: ULong, @@ -2340,6 +2386,7 @@ class LightningRepo @Inject constructor( } suspend fun estimateRoutingFees(bolt11: String): Result = executeWhenNodeRunning("estimateRoutingFees") { + if (shouldPayWithSpark(bolt11, null)) return@executeWhenNodeRunning sparkRepo.estimateFeeSats(bolt11, null) Logger.info("Estimating routing fees for bolt11: $bolt11", context = TAG) lightningService.estimateRoutingFees(bolt11).onSuccess { Logger.info("Routing fees estimated: '$it'", context = TAG) @@ -2350,6 +2397,9 @@ class LightningRepo @Inject constructor( suspend fun estimateRoutingFeesForAmount(bolt11: String, amountSats: ULong): Result = executeWhenNodeRunning("estimateRoutingFeesForAmount") { + if (shouldPayWithSpark(bolt11, amountSats)) { + return@executeWhenNodeRunning sparkRepo.estimateFeeSats(bolt11, amountSats) + } Logger.info("Estimating routing fees for amount: '$amountSats'", context = TAG) lightningService.estimateRoutingFeesForAmount(bolt11, amountSats).onSuccess { Logger.info("Routing fees estimated: '$it'", context = TAG) @@ -2644,6 +2694,7 @@ class LightningRepo @Inject constructor( private const val VSS_KEY_EXTERNAL_SCORES_CACHE = "external_pathfinding_scores_cache" private const val MS_SYNC_LOOP_DEBOUNCE = 500L private const val SYNC_RETRY_DELAY_MS = 15_000L + private const val MSATS_PER_SAT = 1_000uL private val BACKGROUND_STOP_DELAY = 5.seconds private val CHANNELS_USABLE_TIMEOUT = 15.seconds private val CHANNELS_USABLE_POLL_DELAY = 1.seconds diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 02dc4fbb55..5b717f2294 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -81,6 +81,7 @@ class PrivatePaykitRepo @Inject constructor( private val settingsStore: SettingsStore, private val addressReservationRepo: PrivatePaykitAddressReservationRepo, private val lightningRepo: LightningRepo, + private val sparkRepo: SparkRepo, private val walletRepo: WalletRepo, private val publicPaykitRepo: PublicPaykitRepo, private val paymentRequestRepo: PaykitPaymentRequestRepo, @@ -1825,6 +1826,9 @@ class PrivatePaykitRepo @Inject constructor( ) } } + if (PublicPaykitRepo.isLightningPaymentOptionEnabled(settings)) { + sparkUsdEndpoint(sparkRepo)?.let { endpoints += it } + } endpoints } @@ -2322,7 +2326,7 @@ class PrivatePaykitRepo @Inject constructor( it.kind is PaymentKind.Bolt11 } .map { it.id.lowercase() } - .toSet() + .toSet() + sparkRepo.attemptedLightningPaymentHashes() private suspend fun isReceivedInvoiceSettled(paymentHash: String): Boolean = paymentHash in receivedSettledPaymentHashes() @@ -2338,7 +2342,7 @@ class PrivatePaykitRepo @Inject constructor( it.kind is PaymentKind.Bolt11 } .map { it.id } - .toSet() + .toSet() + sparkRepo.receivedLightningPaymentHashes() private suspend fun rememberReceivedInvoicePaymentHash(paymentHash: String, publicKey: String) { if (paymentHash.isBlank()) return diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 1873af4a86..4145078a95 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -118,6 +118,7 @@ class PublicPaykitRepo @Inject constructor( private val settingsStore: SettingsStore, private val clock: Clock, private val usdtRepo: UsdtRepo, + private val sparkRepo: SparkRepo, ) { companion object { private val methodIdPattern = Regex("^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$") @@ -130,6 +131,7 @@ class PublicPaykitRepo @Inject constructor( MethodId.P2sh, MethodId.P2pkh, MethodId.UsdtArbitrum, + MethodId.UsdbSpark, ) private val publicBolt11Expiry = 24.hours @@ -452,6 +454,8 @@ class PublicPaykitRepo @Inject constructor( } } + if (includeLightning) sparkUsdEndpoint(sparkRepo)?.let { endpoints += it } + if (endpoints.isEmpty() && requireEndpoint) throw PublicPaykitError.NoSupportedEndpoint return endpoints @@ -542,6 +546,7 @@ class PublicPaykitRepo @Inject constructor( private suspend fun isPayable(endpoint: Endpoint): Boolean = runSuspendCatching { when (endpoint.methodId) { MethodId.UsdtArbitrum -> Env.isUsdtEnabled && PaykitUsdt.address(endpoint.rawPayload) != null + MethodId.UsdbSpark -> Env.sparkUsdTokenId != null && SparkRepo.isSparkInput(endpoint.value) MethodId.Bolt11 -> { val scan = coreService.decode(endpoint.paymentRequest) as? Scanner.Lightning ?: return@runSuspendCatching false @@ -563,6 +568,16 @@ class PublicPaykitRepo @Inject constructor( }.getOrDefault(false) } +/** + * The Spark dollar endpoint: the wallet's Spark address, published with the Spending endpoints while Spark holds a + * dollar token. Payers send the token to it by identifier. + */ +internal suspend fun sparkUsdEndpoint(sparkRepo: SparkRepo): Endpoint? { + if (Env.sparkUsdTokenId == null || sparkRepo.state.value.usd == null) return null + val address = sparkRepo.sparkAddress().getOrNull() ?: return null + return Endpoint(MethodId.UsdbSpark, value = address, rawPayload = PublicPaykitRepo.serializePayload(address)) +} + data class Endpoint( val methodId: MethodId, val value: String, @@ -582,6 +597,7 @@ enum class MethodId( val isBitkitManaged: Boolean = false, ) { UsdtArbitrum(fixedRawValue = "usdt-arbitrum-address", isBitkitManaged = true), + UsdbSpark(fixedRawValue = "usdb-spark-address", isBitkitManaged = true), Bolt11(fixedRawValue = "btc-lightning-bolt11", isBitkitManaged = true), Lnurl(fixedRawValue = "btc-lightning-lnurl"), P2tr(onchainEndpoint = "p2tr", isOnchain = true, isBitkitManaged = true), diff --git a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt new file mode 100644 index 0000000000..33cad28a4b --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt @@ -0,0 +1,759 @@ +package to.bitkit.repositories + +import androidx.compose.runtime.Stable +import breez_sdk_spark.ConversionChain +import breez_sdk_spark.FeePolicy +import breez_sdk_spark.InputType +import breez_sdk_spark.Payment +import breez_sdk_spark.PaymentDetails +import breez_sdk_spark.PaymentMethod +import breez_sdk_spark.PaymentStatus +import breez_sdk_spark.PrepareSendPaymentResponse +import breez_sdk_spark.SdkEvent +import breez_sdk_spark.SendPaymentMethod +import breez_sdk_spark.SparkAsset +import breez_sdk_spark.TokenMetadata +import breez_sdk_spark.TokenTransactionType +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.LightningActivity +import com.synonym.bitkitcore.PaymentState +import com.synonym.bitkitcore.PaymentType +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.PaymentFailureReason +import org.lightningdevkit.ldknode.PaymentId +import to.bitkit.async.appScope +import to.bitkit.data.SettingsStore +import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env +import to.bitkit.ext.create +import to.bitkit.ext.nowMillis +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.SparkExitBackupV1 +import to.bitkit.services.SparkService +import to.bitkit.utils.AppError +import to.bitkit.utils.Logger +import java.io.File +import java.math.BigInteger +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton +import breez_sdk_spark.PaymentType as SparkPaymentType + +/** + * Lightning without channels, through the Breez SDK on Spark. Savings to Spending funds this wallet, + * receives fall back to it when no channel has the inbound capacity, and sends fall back to it when no + * channel has the outbound capacity. Its payments surface as ldk-node [Event]s through [nodeEvents], so the + * receive and send flows built on node events work for both rails. + */ +@Singleton +class SparkRepo @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val sparkService: SparkService, + private val activityRepo: Provider, + private val settingsStore: SettingsStore, +) { + companion object { + private const val TAG = "SparkRepo" + private const val MSATS_PER_SAT = 1_000uL + + /** Last step of the Spending setup progress, shown once a funding deposit is claimed. */ + private const val SETUP_STEP_DONE = 3 + + /** Arbitrum One, where Bitkit's own USDT wallet lives. */ + private const val ARBITRUM_CHAIN_ID = "42161" + + /** Decimals of Bitkit's dollar amounts, shared with Arbitrum USDT so both format alike. */ + private const val USD_DECIMALS = 6 + + private val SPARK_INPUT_PREFIXES = listOf("spark1", "sparkrt1", "spark:") + + /** Whether [input] looks like a Spark address or invoice, before the SDK parses it. */ + fun isSparkInput(input: String): Boolean = input.trim().lowercase().let { text -> + SPARK_INPUT_PREFIXES.any { text.startsWith(it) } + } + + /** Converts token base units to Bitkit's 6-decimal dollar units. */ + fun toUsdUnits(amount: BigInteger, decimals: Int): ULong = + amount.scaleDecimals(decimals, USD_DECIMALS).toLong().toULong() + + /** Converts Bitkit's 6-decimal dollar units to token base units. */ + fun fromUsdUnits(amount: ULong, decimals: Int): BigInteger = + BigInteger.valueOf(amount.toLong()).scaleDecimals(USD_DECIMALS, decimals) + + private fun BigInteger.scaleDecimals(from: Int, to: Int): BigInteger = when { + to > from -> multiply(BigInteger.TEN.pow(to - from)) + to < from -> divide(BigInteger.TEN.pow(from - to)) + else -> this + } + } + + private val scope = appScope(bgDispatcher, TAG) + + /** Deposits this app broadcast that Spark hasn't listed yet, by txid, so they show as pending at once. */ + private val broadcastDeposits = ConcurrentHashMap() + + /** Sends recorded before Spark knew their fee; its completion event reports 0 until a later sync. */ + private val sendsRecordedWithoutFee = ConcurrentHashMap.newKeySet() + private var usdTokenMetadata: TokenMetadata? = null + + private var eventsJob: Job? = null + + private val _state = MutableStateFlow(SparkState(isSupported = sparkService.isSupported)) + val state: StateFlow = _state.asStateFlow() + + private val _nodeEvents = MutableSharedFlow(extraBufferCapacity = 64) + val nodeEvents: SharedFlow = _nodeEvents.asSharedFlow() + + private val _usdReceived = MutableSharedFlow(extraBufferCapacity = 8) + + /** Fires for each completed incoming dollar transfer, so the app can show its received sheet or notification. */ + val usdReceived: SharedFlow = _usdReceived.asSharedFlow() + + private val _exitStateChanged = MutableSharedFlow(extraBufferCapacity = 1) + + /** Fires once Spark connects and whenever its unilateral exit data changes, so the exit data backup is redone. */ + val exitStateChanged: SharedFlow = _exitStateChanged.asSharedFlow() + + suspend fun start(walletIndex: Int): Result = withContext(bgDispatcher) { + if (!sparkService.isSupported) return@withContext Result.success(Unit) + runSuspendCatching { + if (eventsJob == null) eventsJob = scope.launch { sparkService.events.collect { onSdkEvent(it) } } + sparkService.connect(walletIndex) + importRestoredExitState(walletIndex) + _exitStateChanged.emit(Unit) + refresh() + syncActivities() + }.onFailure { + Logger.error("Failed to start Spark wallet", it, context = TAG) + } + } + + suspend fun stop() = withContext(bgDispatcher) { + runSuspendCatching { sparkService.disconnect() } + .onFailure { Logger.warn("Failed to stop Spark wallet", it, context = TAG) } + _state.update { it.copy(isReady = false) } + } + + suspend fun wipe(walletIndex: Int) = withContext(bgDispatcher) { + runSuspendCatching { sparkService.wipeStorage(walletIndex) } + .onFailure { Logger.error("Failed to wipe Spark wallet", it, context = TAG) } + _state.update { SparkState(isSupported = sparkService.isSupported) } + } + + fun canReceive(): Boolean = _state.value.isReady + + fun canSend(amountSats: ULong): Boolean = _state.value.isReady && _state.value.balanceSats >= amountSats + + suspend fun createInvoice(amountSats: ULong?, description: String, expirySecs: UInt): Result = + withContext(bgDispatcher) { + runSuspendCatching { sparkService.receiveBolt11(amountSats, description, expirySecs) } + } + + /** Returns the Lightning routing fee plus Spark's 0.25% service fee for paying [bolt11]. */ + suspend fun estimateFeeSats(bolt11: String, amountSats: ULong?): Result = withContext(bgDispatcher) { + runSuspendCatching { + val method = sparkService.prepareSend(bolt11, amountSats).paymentMethod + val bolt11 = method as SendPaymentMethod.Bolt11Invoice + bolt11.sparkTransferFeeSats?.takeIf { sparkService.prefersSpark } ?: bolt11.lightningFeeSats + } + } + + /** Pays [bolt11] and returns its payment hash, the id ldk-node payments use, so the send flow can match it. */ + suspend fun payInvoice(bolt11: String, amountSats: ULong?): Result = withContext(bgDispatcher) { + runSuspendCatching { + val prepared = sparkService.prepareSend(bolt11, amountSats) + val paymentHash = (prepared.paymentMethod as SendPaymentMethod.Bolt11Invoice).invoiceDetails.paymentHash + val payment = sparkService.send(prepared) + Logger.info("Sent Spark payment '${payment.id}' with status '${payment.status}'", context = TAG) + paymentHash + }.onFailure { + Logger.error("Failed to pay invoice with Spark", it, context = TAG) + } + } + + /** A Spark invoice for the dollar token, with [amount] in 6-decimal dollar units when set. */ + suspend fun createUsdRequest(amount: ULong?): Result = withContext(bgDispatcher) { + runSuspendCatching { + val tokenId = requireNotNull(Env.sparkUsdTokenId) { "No Spark dollar token on this network" } + val decimals = usdDecimals(tokenId) + sparkService.receiveTokenInvoice(tokenId, amount?.let { fromUsdUnits(it, decimals) }) + } + } + + /** The wallet's static Spark address, published on Paykit as its dollar endpoint. */ + suspend fun sparkAddress(): Result = withContext(bgDispatcher) { + runSuspendCatching { sparkService.sparkAddress() } + } + + /** + * Reads a scanned or pasted Spark address or invoice as a dollar payment. An invoice for another token or for + * sats fails, so it never reaches the dollar send flow. + */ + suspend fun parseUsdRecipient(input: String): Result = withContext(bgDispatcher) { + runSuspendCatching { + val tokenId = requireNotNull(Env.sparkUsdTokenId) { "No Spark dollar token on this network" } + when (val parsed = sparkService.parse(input.trim().let { if (isSparkInput(it)) it.lowercase() else it })) { + is InputType.SparkAddress -> SparkUsdRecipient(parsed.v1.address, amount = null) + is InputType.SparkInvoice if parsed.v1.tokenIdentifier == tokenId -> SparkUsdRecipient( + request = parsed.v1.invoice, + amount = parsed.v1.amount?.let { toUsdUnits(it, usdDecimals(tokenId)) }, + ) + else -> throw SparkUsdError.UnsupportedRecipient() + } + } + } + + /** + * Pays [amount] dollar units of the dollar token to [request], a Spark address or invoice. An invoice that fixes + * its amount is paid as it stands, so no rescaled amount can mismatch it. Returns the payment's id, which its + * activity carries. + */ + suspend fun sendUsd(request: String, amount: ULong, isAmountFixed: Boolean = false): Result = + withContext(bgDispatcher) { + runSuspendCatching { + val tokenId = requireNotNull(Env.sparkUsdTokenId) { "No Spark dollar token on this network" } + val units = if (isAmountFixed) null else fromUsdUnits(amount, usdDecimals(tokenId)) + val payment = sparkService.send(sparkService.prepareTokenSend(request, units, tokenId)) + Logger.info("Sent Spark dollar payment '${payment.id}' with status '${payment.status}'", context = TAG) + refresh() + payment.id + }.onFailure { + Logger.error("Failed to send Spark dollar payment", it, context = TAG) + } + } + + /** + * Quotes converting within the wallet through Flashnet: [amount] dollar units to get when [toUsd], or to give + * when converting to bitcoin. + */ + suspend fun quoteConversion(toUsd: Boolean, amount: ULong): Result = withContext(bgDispatcher) { + runSuspendCatching { + val tokenId = requireNotNull(Env.sparkUsdTokenId) { "No Spark dollar token on this network" } + val decimals = usdDecimals(tokenId) + val prepared = sparkService.prepareConversion(toUsd, fromUsdUnits(amount, decimals), tokenId) + val estimate = requireNotNull(prepared.conversionEstimate) { "No conversion estimate" } + val sats = { value: BigInteger -> value.toLong().toULong() } + val usd = { value: BigInteger -> toUsdUnits(value, decimals) } + // Flashnet reports the swap fee in USDB units both ways (its simulate endpoint, 27 Oct), not in sats. + if (toUsd) { + SparkUsdQuote( + SparkUsdQuote.Kind.TO_USD, + sats(estimate.amountIn), + usd(estimate.amountOut), + usd(estimate.fee), + prepared + ) + } else { + SparkUsdQuote( + SparkUsdQuote.Kind.TO_BTC, + usd(estimate.amountIn), + sats(estimate.amountOut), + usd(estimate.fee), + prepared + ) + } + } + } + + /** Quotes moving [amount] dollar units to Bitkit's USDT wallet at [arbitrumAddress] through Orchestra. */ + suspend fun quoteArbitrumBridge(arbitrumAddress: String, amount: ULong): Result = + withContext(bgDispatcher) { + runSuspendCatching { + val tokenId = requireNotNull(Env.sparkUsdTokenId) { "No Spark dollar token on this network" } + val route = sparkService.crossChainRoutes(arbitrumAddress) + .filter { it.chainId?.removePrefix("eip155:") == ARBITRUM_CHAIN_ID } + .filter { route -> + route.acceptedAssets.any { (it.asset as? SparkAsset.Token)?.tokenIdentifier == tokenId } + } + .sortedBy { if (it.asset.uppercase() == "USDT0") 0 else 1 } + .firstOrNull { it.asset.uppercase().startsWith("USDT") } + ?: throw SparkUsdError.NoRoute() + val prepared = sparkService.prepareCrossChain( + arbitrumAddress, + route, + fromUsdUnits(amount, usdDecimals(tokenId)), + tokenId, + ) + val method = prepared.paymentMethod as SendPaymentMethod.CrossChainAddress + val decimals = route.decimals.toInt() + SparkUsdQuote( + kind = SparkUsdQuote.Kind.TO_ARBITRUM, + pay = amount, + receive = toUsdUnits(method.estimatedOut, decimals), + fee = toUsdUnits(method.feeAmount, decimals), + prepared = prepared, + ) + } + } + + /** Runs a conversion or bridge [quote] and refreshes the balances. */ + suspend fun execute(quote: SparkUsdQuote): Result = withContext(bgDispatcher) { + runSuspendCatching { + val payment = sparkService.send(quote.prepared) + Logger.info("Ran Spark dollar '${quote.kind}' with status '${payment.status}'", context = TAG) + refresh() + }.onFailure { + Logger.error("Failed to run Spark dollar '${quote.kind}'", it, context = TAG) + } + } + + private suspend fun usdDecimals(tokenId: String): Int = usdMetadata(tokenId).decimals.toInt() + + private suspend fun usdMetadata(tokenId: String): TokenMetadata = usdTokenMetadata + ?: requireNotNull(sparkService.tokenMetadata(tokenId)) { "Unknown Spark token '$tokenId'" } + .also { usdTokenMetadata = it } + + /** Estimates what Spark keeps from a deposit to claim it, the way the SDK does before the provider quotes it. */ + suspend fun estimateDepositClaimFeeSats(amountSats: ULong): Result = withContext(bgDispatcher) { + runSuspendCatching { SparkService.maxDepositClaimFeeSats(sparkService.fastestFeeRate(), amountSats) } + } + + suspend fun depositAddress(): Result = withContext(bgDispatcher) { + runSuspendCatching { sparkService.depositAddress() } + } + + /** Moves the whole Spark balance to the on-chain [address], fees included, for Spending to Savings. */ + suspend fun withdrawAll(address: String): Result = withContext(bgDispatcher) { + runSuspendCatching { + val prepared = sparkService.prepareSend(address, _state.value.balanceSats, FeePolicy.FEES_INCLUDED) + sparkService.send(prepared) + refresh() + }.onFailure { + Logger.error("Failed to withdraw from Spark", it, context = TAG) + } + } + + /** + * Lists a deposit the app just broadcast as pending right away, then syncs so Spark sees it sooner than its + * minute-long background sync; Spark's own deposit state replaces it once listed. + */ + fun onDepositBroadcast(txid: String, amountSats: ULong) { + broadcastDeposits[txid] = amountSats + _state.update { it.withBroadcastDeposits() } + scope.launch { + runSuspendCatching { + sparkService.syncWallet() + refresh() + }.onFailure { Logger.warn("Failed to sync Spark after deposit '$txid'", it, context = TAG) } + } + } + + private fun SparkState.withBroadcastDeposits(): SparkState { + val known = deposits.map { it.txid }.toSet() + val local = broadcastDeposits.filterKeys { it !in known } + .map { (txid, amount) -> SparkDeposit(txid = txid, amountSats = amount, isClaimed = false) } + if (local.isEmpty()) return this + val all = local + deposits + return copy( + deposits = all.toImmutableList(), + pendingDepositSats = all.filterNot { it.isClaimed }.sumOf { it.amountSats }, + ) + } + + /** The exit data backup, or null while Spark is not connected and there is nothing to back up. */ + suspend fun exitBackup(): Result = withContext(bgDispatcher) { + runSuspendCatching { + val network = sparkService.networkName + if (!sparkService.isConnected || network == null) return@runSuspendCatching null + SparkExitBackupV1(createdAt = nowMillis(), network = network, exitState = sparkService.exportExitState()) + } + } + + /** + * Puts a restored exit data backup back: at once while Spark is connected, otherwise right after it connects, + * before it is used, so a restored wallet can exit on-chain even while the Spark operators are unreachable. + */ + suspend fun restoreExitBackup(backup: SparkExitBackupV1, walletIndex: Int): Result = + withContext(bgDispatcher) { + runSuspendCatching { + if (backup.network != sparkService.networkName) { + Logger.warn("Skipped Spark exit data from network '${backup.network}'", context = TAG) + return@runSuspendCatching + } + File(Env.sparkExitRestorePath(walletIndex)).writeText(backup.exitState) + if (sparkService.isConnected) importRestoredExitState(walletIndex) + } + } + + private suspend fun importRestoredExitState(walletIndex: Int) { + val file = File(Env.sparkExitRestorePath(walletIndex)) + if (!file.exists()) return + runSuspendCatching { sparkService.importExitState(file.readText()) } + .onSuccess { + file.delete() + Logger.info( + "Imported Spark exit data: '${it.importedLeaves}' leaves, '${it.skippedForeignLeaves}' foreign, " + + "'${it.skippedConflictingLeaves}' conflicting, '${it.skippedChains}' already held", + context = TAG, + ) + } + .onFailure { Logger.error("Failed to import Spark exit data", it, context = TAG) } + } + + suspend fun refreshState(): Result = withContext(bgDispatcher) { + if (!sparkService.isConnected) return@withContext Result.success(Unit) + runSuspendCatching { refresh() }.onFailure { Logger.warn("Failed to refresh Spark state", it, context = TAG) } + } + + private suspend fun refresh() { + val info = sparkService.getInfo() + val pending = sparkService.listUnclaimedDeposits().map { + SparkDeposit(txid = it.txid, amountSats = it.amountSats, isClaimed = false) + } + val payments = sparkService.listPayments() + val claimed = payments + .filter { it.method == PaymentMethod.DEPOSIT && it.status == PaymentStatus.COMPLETED } + .mapNotNull { payment -> + val deposit = payment.details as? PaymentDetails.Deposit ?: return@mapNotNull null + SparkDeposit( + txid = deposit.txId, + amountSats = payment.amount.toLong().toULong(), + isClaimed = true, + feeSats = payment.fees.toLong().toULong(), + claimedAt = payment.timestamp, + ) + } + (pending + claimed).forEach { broadcastDeposits.remove(it.txid) } + val usd = Env.sparkUsdTokenId?.let { tokenId -> + runSuspendCatching { usdState(tokenId, info.tokenBalances[tokenId]?.balance) } + .onFailure { Logger.warn("Failed to refresh Spark dollar balance", it, context = TAG) } + .getOrElse { _state.value.usd } + } + _state.update { + it.copy( + isReady = true, + balanceSats = info.balanceSats, + pendingDepositSats = pending.sumOf { deposit -> deposit.amountSats }, + deposits = (pending + claimed).toImmutableList(), + withdrawals = payments.toWithdrawals(), + recoverableFundsSats = info.recoverableFundsSats, + usd = usd, + ).withBroadcastDeposits() + } + payments.filter { it.id in sendsRecordedWithoutFee && it.fees.toLong() > 0 }.forEach { recordActivity(it) } + markTransfers(payments) + } + + private fun List.toWithdrawals() = filter { it.status != PaymentStatus.FAILED } + .mapNotNull { payment -> + val withdraw = payment.details as? PaymentDetails.Withdraw ?: return@mapNotNull null + SparkWithdrawal( + txid = withdraw.txId, + amountSats = payment.amount.toLong().toULong(), + feeSats = payment.fees.toLong().toULong(), + ) + } + .toImmutableList() + + private suspend fun markTransfers(payments: List) { + val activities = activityRepo.get() + payments.filter { it.status != PaymentStatus.FAILED } + .mapNotNull { (it.details as? PaymentDetails.Withdraw)?.txId } + .forEach { txid -> + val activity = activities.getOnchainActivityByTxId(txid)?.takeUnless { it.isTransfer } ?: return@forEach + activities.updateActivity(activity.id, Activity.Onchain(activity.copy(isTransfer = true))) + } + } + + /** + * The completion event of a Spark Lightning send that has already settled. A Spark-to-Spark payment settles inside + * the send call, so its event can fire before the caller starts watching for it. + */ + suspend fun settledSendEvent(paymentHash: String): Event? = withContext(bgDispatcher) { + if (!sparkService.isConnected) return@withContext null + runSuspendCatching { sparkService.listPayments() } + .onFailure { Logger.warn("Failed to list Spark payments", it, context = TAG) } + .getOrNull() + ?.firstOrNull { + val hash = (it.details as? PaymentDetails.Lightning)?.htlcDetails?.paymentHash + it.paymentType == SparkPaymentType.SEND && hash.equals(paymentHash, ignoreCase = true) + } + ?.toNodeEvent() + } + + /** Payment hashes of the Lightning payments Spark received. */ + suspend fun receivedLightningPaymentHashes(): Set = + lightningPaymentHashes { it.paymentType == SparkPaymentType.RECEIVE && it.status == PaymentStatus.COMPLETED } + + /** Payment hashes of the Lightning payments Spark sent or is sending. */ + suspend fun attemptedLightningPaymentHashes(): Set = + lightningPaymentHashes { it.paymentType == SparkPaymentType.SEND && it.status != PaymentStatus.FAILED } + + private suspend fun lightningPaymentHashes(filter: (Payment) -> Boolean): Set = withContext(bgDispatcher) { + if (!sparkService.isConnected) return@withContext emptySet() + runSuspendCatching { sparkService.listPayments() } + .onFailure { Logger.warn("Failed to list Spark payments", it, context = TAG) } + .getOrDefault(emptyList()) + .filter(filter) + .mapNotNull { (it.details as? PaymentDetails.Lightning)?.htlcDetails?.paymentHash?.lowercase() } + .toSet() + } + + private suspend fun usdState(tokenId: String, balance: BigInteger?): SparkUsdState { + val metadata = usdMetadata(tokenId) + val decimals = metadata.decimals.toInt() + val transfers = sparkService.listPayments().mapNotNull { it.toUsdTransfer(tokenId, decimals) } + return SparkUsdState( + ticker = metadata.ticker.trim(), + balance = toUsdUnits(balance ?: BigInteger.ZERO, decimals), + transfers = transfers.sortedByDescending { it.timestamp }.toImmutableList(), + ) + } + + /** + * Reads a Spark payment as dollar activity: a token transfer, or a conversion or bridge with the token on one + * side. Conversion fees come in USDB units on both sides (Flashnet's fee plus a zero Spark transfer fee). + */ + private fun Payment.toUsdTransfer(tokenId: String, decimals: Int): SparkUsdTransfer? { + val usd = { value: BigInteger -> toUsdUnits(value, decimals) } + val conversion = conversionDetails?.conversions?.firstOrNull { + it.from.asset.identifier == tokenId || it.to.asset.identifier == tokenId + } + if (conversion != null) { + val fee = usd(conversion.from.fee + conversion.to.fee) + return when { + conversion.to.asset.identifier == tokenId -> SparkUsdTransfer( + id = id, + kind = SparkUsdTransfer.Kind.TO_USD, + status = status, + amount = usd(conversion.to.amount), + fee = fee, + timestamp = timestamp, + sats = conversion.from.amount.toLong().toULong(), + ) + conversion.to.chain is ConversionChain.External -> SparkUsdTransfer( + id = id, + kind = SparkUsdTransfer.Kind.TO_ARBITRUM, + status = status, + amount = usd(conversion.from.amount), + fee = fee, + timestamp = timestamp, + ) + else -> SparkUsdTransfer( + id = id, + kind = SparkUsdTransfer.Kind.TO_BTC, + status = status, + amount = usd(conversion.from.amount), + fee = fee, + timestamp = timestamp, + sats = conversion.to.amount.toLong().toULong(), + ) + } + } + val token = details as? PaymentDetails.Token ?: return null + if (token.metadata.identifier != tokenId || token.txType != TokenTransactionType.TRANSFER) return null + return SparkUsdTransfer( + id = id, + kind = when (paymentType) { + SparkPaymentType.RECEIVE -> SparkUsdTransfer.Kind.RECEIVED + SparkPaymentType.SEND -> SparkUsdTransfer.Kind.SENT + }, + status = status, + amount = usd(amount), + fee = usd(fees), + timestamp = timestamp, + txHash = token.txHash, + counterparty = token.invoiceDetails?.invoice, + ) + } + + private suspend fun syncActivities() { + sparkService.listPayments().forEach { recordActivity(it) } + } + + private suspend fun onSdkEvent(event: SdkEvent) { + runSuspendCatching { + when (event) { + is SdkEvent.Synced -> refresh() + is SdkEvent.PaymentSucceeded -> onPayment(event.payment) + is SdkEvent.PaymentFailed -> onPayment(event.payment) + is SdkEvent.PaymentPending if event.payment.method == PaymentMethod.TOKEN -> refresh() + is SdkEvent.PaymentPending -> recordActivity(event.payment) + is SdkEvent.UnclaimedDeposits -> Logger.warn( + "Found '${event.unclaimedDeposits.size}' unclaimed Spark deposits", + context = TAG, + ) + is SdkEvent.NewDeposits -> refresh() + is SdkEvent.UnilateralExitStateChanged -> _exitStateChanged.emit(Unit) + is SdkEvent.ClaimedDeposits -> { + refresh() + settingsStore.update { + if (it.lightningSetupStep > 0) it.copy(lightningSetupStep = SETUP_STEP_DONE) else it + } + } + else -> Unit + } + }.onFailure { + Logger.error("Failed to handle Spark event '$event'", it, context = TAG) + } + } + + private suspend fun onPayment(payment: Payment) { + refresh() + recordActivity(payment) + payment.toNodeEvent()?.let { _nodeEvents.emit(it) } + val received = _state.value.usd?.transfers?.firstOrNull { it.id == payment.id } + if (received?.kind == SparkUsdTransfer.Kind.RECEIVED && received.status == PaymentStatus.COMPLETED) { + _usdReceived.emit(received) + } + } + + /** Syncs with Spark now, so a payment that arrived while the app was away shows without waiting for its sync. */ + suspend fun syncNow(): Result = withContext(bgDispatcher) { + if (!sparkService.isConnected) return@withContext Result.success(Unit) + runSuspendCatching { + sparkService.syncWallet() + refresh() + } + } + + private suspend fun recordActivity(payment: Payment) { + val lightning = payment.details as? PaymentDetails.Lightning ?: return + if (payment.status == PaymentStatus.PENDING && payment.paymentType == SparkPaymentType.RECEIVE) { + return + } + val id = lightning.htlcDetails.paymentHash + val existing = (activityRepo.get().getActivity(id).getOrNull() as? Activity.Lightning)?.v1 + val activity = LightningActivity.create( + id = id, + txType = when (payment.paymentType) { + SparkPaymentType.SEND -> PaymentType.SENT + SparkPaymentType.RECEIVE -> PaymentType.RECEIVED + }, + status = when (payment.status) { + PaymentStatus.COMPLETED -> PaymentState.SUCCEEDED + PaymentStatus.PENDING -> PaymentState.PENDING + PaymentStatus.FAILED -> PaymentState.FAILED + }, + value = payment.amount.toLong().toULong(), + invoice = lightning.invoice, + timestamp = payment.timestamp, + fee = payment.fees.toLong().toULong(), + message = lightning.description.orEmpty(), + preimage = lightning.htlcDetails.preimage, + contact = existing?.contact, + seenAt = existing?.seenAt, + ) + activityRepo.get().upsertActivity(Activity.Lightning(activity)) + if (payment.paymentType == SparkPaymentType.SEND && payment.fees.toLong() == 0L) { + sendsRecordedWithoutFee.add(payment.id) + } else { + sendsRecordedWithoutFee.remove(payment.id) + } + } + + private fun Payment.toNodeEvent(): Event? { + val lightning = details as? PaymentDetails.Lightning ?: return null + val hash = lightning.htlcDetails.paymentHash + val amountMsat = amount.toLong().toULong() * MSATS_PER_SAT + return when (paymentType to status) { + SparkPaymentType.RECEIVE to PaymentStatus.COMPLETED -> + Event.PaymentReceived(hash, hash, amountMsat, emptyList()) + SparkPaymentType.SEND to PaymentStatus.COMPLETED -> + Event.PaymentSuccessful( + hash, + hash, + lightning.htlcDetails.preimage, + fees.toLong().toULong() * MSATS_PER_SAT + ) + SparkPaymentType.SEND to PaymentStatus.FAILED -> + Event.PaymentFailed(hash, hash, PaymentFailureReason.UNEXPECTED_ERROR) + else -> null + } + } +} + +data class SparkState( + val isSupported: Boolean = false, + val isReady: Boolean = false, + val balanceSats: ULong = 0uL, + /** Deposits seen on-chain that Spark claims into [balanceSats] after 3 confirmations. */ + val pendingDepositSats: ULong = 0uL, + val deposits: ImmutableList = persistentListOf(), + val withdrawals: ImmutableList = persistentListOf(), + val recoverableFundsSats: ULong = 0uL, + /** The dollar token balance, or null where this network has no dollar token or Spark hasn't loaded it. */ + val usd: SparkUsdState? = null, +) + +/** A Transfer to Savings out of Spark, with what Spark kept for it. */ +data class SparkWithdrawal( + val txid: String, + val amountSats: ULong, + val feeSats: ULong, +) + +/** The dollar token held on Spark, in 6-decimal dollar units like Arbitrum USDT. */ +data class SparkUsdState( + val ticker: String, + val balance: ULong, + val transfers: ImmutableList = persistentListOf(), +) + +/** + * Dollar activity on Spark. [amount] and [fee] are in dollar units; [sats] is the bitcoin side of a conversion, and + * [counterparty] the Spark invoice a transfer paid or was paid through, when known. + */ +data class SparkUsdTransfer( + val id: String, + val kind: Kind, + val status: PaymentStatus, + val amount: ULong, + val fee: ULong, + val timestamp: ULong, + val txHash: String? = null, + val counterparty: String? = null, + val sats: ULong? = null, +) { + enum class Kind { SENT, RECEIVED, TO_USD, TO_BTC, TO_ARBITRUM } + + /** Whether dollars came in: a receive or a conversion to dollars. */ + val isIncoming: Boolean get() = kind == Kind.RECEIVED || kind == Kind.TO_USD +} + +/** Where a dollar payment goes: a Spark address or invoice, with the amount an invoice fixes. */ +data class SparkUsdRecipient(val request: String, val amount: ULong?) + +/** + * A priced conversion or bridge, ready to run. [pay] and [receive] are in sats for the bitcoin side and in dollar + * units otherwise; [fee] is always in dollar units. + */ +@Stable +data class SparkUsdQuote( + val kind: Kind, + val pay: ULong, + val receive: ULong, + val fee: ULong, + val prepared: PrepareSendPaymentResponse, +) { + enum class Kind { TO_USD, TO_BTC, TO_ARBITRUM } +} + +sealed class SparkUsdError(message: String) : AppError(message) { + class UnsupportedRecipient : SparkUsdError("Not a Spark address or an invoice for the dollar token") + class NoRoute : SparkUsdError("No cross-chain route pays Arbitrum USDT from the dollar token") +} + +/** An on-chain deposit into Spark: pending until Spark claims it after 3 confirmations. */ +data class SparkDeposit( + val txid: String, + val amountSats: ULong, + val isClaimed: Boolean, + val feeSats: ULong = 0uL, + val claimedAt: ULong? = null, +) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index c7c9173a01..5166c896b2 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -15,7 +15,9 @@ import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -74,6 +76,7 @@ class WalletRepo @Inject constructor( private val transferRepo: TransferRepo, private val activityRepo: ActivityRepo, private val hwWalletRepo: HwWalletRepo, + private val sparkRepo: SparkRepo, ) { private val repoScope = appScope(bgDispatcher, TAG) @@ -112,6 +115,16 @@ class WalletRepo @Inject constructor( _balanceState.update { state -> state.copy(hardwareWallets = wallets.map { it.toBalance() }) } } } + repoScope.launch { + sparkRepo.state.map { it.isReady }.distinctUntilChanged().collect { isReady -> + if (isReady && walletExists()) refreshBip21() + } + } + repoScope.launch { + sparkRepo.state.map { it.balanceSats to it.pendingDepositSats }.distinctUntilChanged().collect { + if (walletExists()) syncBalances() + } + } } private suspend fun updateReceiveState( @@ -403,7 +416,8 @@ class WalletRepo @Inject constructor( fun refreshMaxSendLightning() { val state = lightningRepo.lightningState.value if (!state.nodeLifecycleState.isRunning()) return - _balanceState.update { it.copy(maxSendLightningSats = state.channels.totalNextOutboundHtlcLimitSats()) } + val maxSendSats = maxOf(state.channels.totalNextOutboundHtlcLimitSats(), sparkRepo.state.value.balanceSats) + _balanceState.update { it.copy(maxSendLightningSats = maxSendSats) } } suspend fun syncBalances() { @@ -960,11 +974,15 @@ class WalletRepo @Inject constructor( } } + /** Spark receives any amount without inbound liquidity, so no channel or CJIT is needed. */ + fun canReceiveWithSpark(): Boolean = sparkRepo.canReceive() + suspend fun inboundLiquiditySats(): ULong = withContext(bgDispatcher) { return@withContext currentReadyChannels().calculateRemoteBalance() } private fun canCreateLightningInvoice(amountSats: ULong?): Boolean { + if (sparkRepo.canReceive()) return true val readyChannels = currentReadyChannels() return ReceiveLiquidityDecision.canCreateLightningInvoice( hasReadyChannels = readyChannels.isNotEmpty(), diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index b59c70edd1..5a1116803d 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -171,8 +171,9 @@ class CoreService @Inject constructor( } } + /** @param blockOnError whether a failed or unexpected geocheck counts as blocked. */ @Suppress("KotlinConstantConditions") - suspend fun isGeoBlocked(): Boolean { + suspend fun isGeoBlocked(blockOnError: Boolean = false): Boolean { val tag = "GeoCheck" if (!Env.isGeoblockingEnabled) { Logger.verbose("Geoblocking disabled via build config", context = tag) @@ -197,15 +198,15 @@ class CoreService @Inject constructor( else -> { Logger.warn( - "Unexpected status code: ${response.status.value}, defaulting to false", + "Unexpected status code: ${response.status.value}, defaulting to '$blockOnError'", context = tag ) - false + blockOnError } } }.onFailure { - Logger.warn("Error. defaulting isGeoBlocked to false", context = tag) - }.getOrDefault(false) + Logger.warn("Failed geocheck, defaulting isGeoBlocked to '$blockOnError'", it, context = tag) + }.getOrDefault(blockOnError) } } diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 78a272c4c9..a6c9ca76d7 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -32,9 +32,11 @@ import org.lightningdevkit.ldknode.CoinSelectionAlgorithm import org.lightningdevkit.ldknode.Config import org.lightningdevkit.ldknode.ConfirmationStatus import org.lightningdevkit.ldknode.ElectrumSyncConfig +import org.lightningdevkit.ldknode.EsploraSyncConfig import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.FeeRate import org.lightningdevkit.ldknode.KeychainKind +import org.lightningdevkit.ldknode.LightningBalance import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus @@ -45,6 +47,7 @@ import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PeerDetails +import org.lightningdevkit.ldknode.PendingSweepBalance import org.lightningdevkit.ldknode.PublicKey import org.lightningdevkit.ldknode.RouteParametersConfig import org.lightningdevkit.ldknode.ScoringFeeParameters @@ -63,6 +66,7 @@ import to.bitkit.di.BgDispatcher import to.bitkit.di.IoDispatcher import to.bitkit.env.Defaults import to.bitkit.env.Env +import to.bitkit.ext.counterpartyNodeId import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.uByteList import to.bitkit.ext.uri @@ -107,6 +111,40 @@ private class EventListenerContext : AbstractCoroutineContextElement(Key) { companion object Key : CoroutineContext.Key } +/** + * Builds the node config. LSP peers are not trusted for zero-conf channels or to broadcast our force + * closes: Blocktank no longer serves channels, so our node must broadcast and fee-bump its own closes. + */ +internal fun nodeConfig( + base: Config, + storageDirPath: String, + onchainWalletAccounts: List, +): Config = base.copy( + storageDirPath = storageDirPath, + network = Env.network, + trustedPeers0conf = emptyList(), + anchorChannelsConfig = AnchorChannelsConfig( + trustedPeersNoReserve = emptyList(), + perChannelReserveSats = 1u, + ), + probingLiquidityLimitMultiplier = 1uL, + includeUntrustedPendingInSpendable = true, + onchainWalletAccounts = onchainWalletAccounts, +) + +/** Returns the stored peers with no channel and no claimable balance, or none while any sweep is pending. */ +internal fun idlePeerIds( + peers: List, + channels: List, + lightningBalances: List, + pendingSweeps: List, +): List { + if (pendingSweeps.isNotEmpty()) return emptyList() + val busyPeerIds = channels.map { it.counterpartyNodeId }.toSet() + + lightningBalances.map { it.counterpartyNodeId() } + return peers.filter { it.isPersisted && it.nodeId !in busyPeerIds }.map { it.nodeId } +} + internal fun enabledOnchainWalletAccountConfigs( records: List, walletIndex: Int, @@ -235,8 +273,6 @@ class LightningService internal constructor( private val _syncStatusChanged = MutableSharedFlow(extraBufferCapacity = 1) val syncStatusChanged: SharedFlow = _syncStatusChanged.asSharedFlow() - private var trustedPeers: List = emptyList() - private var listenerJob: Job? = null // Release of the previous node's rust handle (free_node). Rebuild and destructive storage work @@ -248,13 +284,12 @@ class LightningService internal constructor( walletIndex: Int, customServerUrl: String? = null, customRgsServerUrl: String? = null, - trustedPeers: List? = null, channelMigration: ChannelDataMigration? = null, ) { awaitNodeRelease() Logger.debug("Building node…", context = TAG) - val config = config(walletIndex, trustedPeers) + val config = config(walletIndex) val builtNode = build( walletIndex, customServerUrl, @@ -268,28 +303,12 @@ class LightningService internal constructor( Logger.info("LDK node setup", context = TAG) } - private suspend fun config( - walletIndex: Int, - trustedPeers: List?, - ): Config { + private suspend fun config(walletIndex: Int): Config { val dirPath = Env.ldkStoragePath(walletIndex) - this.trustedPeers = trustedPeers?.takeIf { it.isNotEmpty() } ?: Env.trustedLnPeers.also { - Logger.warn("Missing trusted peers from LSP, falling back to preconfigured env peers", context = TAG) - } - - val trustedPeerNodeIds = this.trustedPeers.map { it.nodeId } - - return defaultConfig().copy( + return nodeConfig( + base = defaultConfig(), storageDirPath = dirPath, - network = Env.network, - trustedPeers0conf = trustedPeerNodeIds, - anchorChannelsConfig = AnchorChannelsConfig( - trustedPeersNoReserve = trustedPeerNodeIds, - perChannelReserveSats = 1u, - ), - probingLiquidityLimitMultiplier = 1uL, - includeUntrustedPendingInSpendable = true, onchainWalletAccounts = enabledOnchainWalletAccountConfigs(watchOnlyAccountStore.load(), walletIndex), ) } @@ -378,6 +397,20 @@ class LightningService internal constructor( } private suspend fun Builder.configureChainSource(customServerUrl: String? = null) { + Env.regtestEsploraUrl?.let { esploraUrl -> + Logger.info("Using onchain source Esplora url: '$esploraUrl'", context = TAG) + setChainSourceEsplora( + serverUrl = esploraUrl, + config = EsploraSyncConfig( + BackgroundSyncConfig( + onchainWalletSyncIntervalSecs = Env.walletSyncIntervalSecs, + lightningWalletSyncIntervalSecs = Env.walletSyncIntervalSecs, + feeRateCacheUpdateIntervalSecs = Env.walletSyncIntervalSecs, + ), + ), + ) + return + } val serverUrl = customServerUrl ?: settingsStore.data.first().electrumServer Logger.info("Using onchain source Electrum Sever url: $serverUrl", context = TAG) setChainSourceElectrum( @@ -604,20 +637,6 @@ class LightningService internal constructor( return true } - // reset graph if missing trusted peers - val missing = trustedPeers.filter { it.nodeId !in nodes } - if (missing.size == trustedPeers.size) { - val rgsTimestamp = node.status().latestRgsSnapshotTimestamp - val missingIds = missing.joinToString { it.nodeId.take(NODE_ID_PREVIEW_LEN) } - Logger.warn( - "Network graph missing all ${trustedPeers.size} trusted peers: [$missingIds] " + - "(graphNodes=${nodes.size}, channels=${graph.listChannels().size}, " + - "rgsTimestamp=$rgsTimestamp)", - context = TAG, - ) - return false - } - // reset graph if missing channel counterparty val channels = node.listChannels().filter { it.isUsable } val missingCounterparties = channels.filter { it.counterpartyNodeId !in nodes } @@ -631,20 +650,7 @@ class LightningService internal constructor( return false } - if (missing.isNotEmpty()) { - val ids = missing.joinToString { it.nodeId } - Logger.debug( - "Network graph missing ${missing.size}/${trustedPeers.size} trusted peers: [$ids]", - context = TAG, - ) - } - - val total = trustedPeers.size - val count = total - missing.size - Logger.debug( - "Network graph validated: $count/$total trusted peers present", - context = TAG, - ) + Logger.debug("Network graph validated: '${channels.size}' channel counterparties present", context = TAG) return true } @@ -744,39 +750,26 @@ class LightningService internal constructor( } // region peers - suspend fun connectToTrustedPeers() { + /** + * Disconnects stored peers we have nothing pending with, which also drops them from the peer store. + * Skips the run while any closed channel still awaits its sweep, as a sweep does not name its peer. + */ + suspend fun disconnectIdlePeers() { val node = this.node ?: throw ServiceError.NodeNotSetup() ServiceQueue.LDK.background(ldkQueue) { - for (peer in trustedPeers) { - try { - node.connect(peer.nodeId, peer.address, persist = true) - Logger.info("Connected to trusted peer: $peer", context = TAG) - } catch (e: NodeException) { - Logger.error("Peer connect error: $peer", LdkError(e), context = TAG) - } - } - - verifyTrustedPeersOrFallback(node) - } - } - - private fun verifyTrustedPeersOrFallback(node: Node) { - val connectedPeerIds = node.listPeers().map { it.nodeId }.toSet() - val trustedConnected = trustedPeers.count { it.nodeId in connectedPeerIds } - - if (trustedConnected == 0 && trustedPeers.isNotEmpty()) { - Logger.warn("No trusted peers connected, falling back to preconfigured env peers", context = TAG) - for (peer in Env.trustedLnPeers) { - try { - node.connect(peer.nodeId, peer.address, persist = true) - Logger.info("Connected to fallback peer: $peer", context = TAG) - } catch (e: NodeException) { - Logger.error("Fallback peer connect error: $peer", LdkError(e), context = TAG) - } + val balances = node.listBalances() + val idlePeerIds = idlePeerIds( + peers = node.listPeers(), + channels = node.listChannels(), + lightningBalances = balances.lightningBalances, + pendingSweeps = balances.pendingBalancesFromChannelClosures, + ) + for (nodeId in idlePeerIds) { + runCatching { node.disconnect(nodeId) } + .onSuccess { Logger.info("Disconnected idle peer '$nodeId'", context = TAG) } + .onFailure { Logger.warn("Failed to disconnect idle peer '$nodeId'", it, context = TAG) } } - } else { - Logger.info("Connected to $trustedConnected/${trustedPeers.size} trusted peers", context = TAG) } } @@ -815,17 +808,6 @@ class LightningService internal constructor( } } - fun getLspPeerNodeIds(): Set = trustedPeers.map { it.nodeId }.toSet() - - fun separateTrustedChannels(channels: List): Pair, List> { - val trustedPeerIds = getLspPeerNodeIds() - val trusted = channels.filter { it.counterpartyNodeId in trustedPeerIds } - val nonTrusted = channels.filter { it.counterpartyNodeId !in trustedPeerIds } - return trusted to nonTrusted - } - - fun isTrustedPeer(nodeId: String): Boolean = nodeId in getLspPeerNodeIds() - // endregion // region channels @@ -879,11 +861,6 @@ class LightningService internal constructor( val userChannelId = channel.userChannelId val counterpartyNodeId = channel.counterpartyNodeId - // Prevent force closing channels with trusted peers (LSP nodes) - if (force && isTrustedPeer(counterpartyNodeId)) { - throw TrustedPeerForceCloseException() - } - try { ServiceQueue.LDK.background(ldkQueue) { Logger.debug("Initiating channel close (force=$force): '$channelId'", context = TAG) @@ -1140,7 +1117,7 @@ class LightningService internal constructor( } } }.onFailure { - loggerLdk.dumpNetworkGraphInfo(node, trustedPeers, bolt11) + loggerLdk.dumpNetworkGraphInfo(node, bolt11) }.getOrThrow() } @@ -1545,7 +1522,7 @@ class LightningService internal constructor( private fun dumpNetworkGraphInfo(bolt11: String) { val node = this.node ?: return - loggerLdk.dumpNetworkGraphInfo(node, trustedPeers, bolt11) + loggerLdk.dumpNetworkGraphInfo(node, bolt11) } suspend fun exportNetworkGraphToFile( @@ -1566,10 +1543,6 @@ data class NetworkGraphInfo( val latestRgsSyncTimestamp: ULong?, ) -class TrustedPeerForceCloseException : AppError( - "Cannot force close channel with trusted peer. Force close is disabled for Blocktank LSP channels." -) - class NetworkGraphCacheDeleteError : AppError("Failed to delete network graph cache") /** Exact signed transaction inputs/prevouts, never an estimate or a backend-absence inference. */ diff --git a/app/src/main/java/to/bitkit/services/LspNotificationsService.kt b/app/src/main/java/to/bitkit/services/LspNotificationsService.kt deleted file mode 100644 index 955b775d92..0000000000 --- a/app/src/main/java/to/bitkit/services/LspNotificationsService.kt +++ /dev/null @@ -1,67 +0,0 @@ -package to.bitkit.services - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext -import to.bitkit.async.ServiceQueue -import to.bitkit.data.keychain.Keychain -import to.bitkit.data.keychain.Keychain.Key -import to.bitkit.di.BgDispatcher -import to.bitkit.env.Env -import to.bitkit.env.Env.derivationName -import to.bitkit.ext.nowTimestamp -import to.bitkit.ext.toHex -import to.bitkit.utils.Crypto -import to.bitkit.utils.Logger -import to.bitkit.utils.ServiceError -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class LspNotificationsService @Inject constructor( - @BgDispatcher private val bgDispatcher: CoroutineDispatcher, - private val lightningService: LightningService, - private val keychain: Keychain, - private val crypto: Crypto, -) { - suspend fun registerDevice(deviceToken: String) = withContext(bgDispatcher) { - val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted() - - Logger.debug("Registering device for notifications…") - - val timestamp = nowTimestamp() - val messageToSign = "$derivationName$deviceToken$timestamp" - - val signature = lightningService.sign(messageToSign) - - val keypair = crypto.generateKeyPair() - val publicKey = keypair.publicKey.toHex() - Logger.debug("Notification encryption public key: $publicKey") - - // New keypair for each token registration - if (keychain.exists(Key.PUSH_NOTIFICATION_PRIVATE_KEY.name)) { - keychain.delete(Key.PUSH_NOTIFICATION_PRIVATE_KEY.name) - } - keychain.save(Key.PUSH_NOTIFICATION_PRIVATE_KEY.name, keypair.privateKey) - - ServiceQueue.CORE.background { - com.synonym.bitkitcore.registerDevice( - deviceToken = deviceToken, - publicKey = publicKey, - features = Env.pushNotificationFeatures.map { it.toString() }, - nodeId = nodeId, - isoTimestamp = "$timestamp", - signature = signature, - customUrl = Env.blocktankNotificationApiUrl, - isProduction = !Env.isDebug, - ) - } - - // Cache token so we can avoid re-registering - if (keychain.exists(Key.PUSH_NOTIFICATION_TOKEN.name)) { - keychain.delete(Key.PUSH_NOTIFICATION_TOKEN.name) - } - keychain.saveString(Key.PUSH_NOTIFICATION_TOKEN.name, deviceToken) - - Logger.info("Device registered for notifications") - } -} diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index eaa3af3c4f..352e7f7951 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -1451,10 +1451,7 @@ class MigrationService internal constructor( val peers = json.decodeFromString>(String(data)) if (peers.isEmpty()) return emptyList() - val trustedIds = Env.trustedLnPeers.map { it.nodeId }.toSet() - val uris = peers - .filter { it.pubKey !in trustedIds } - .map { "${it.pubKey}@${it.address}:${it.port}" } + val uris = peers.map { "${it.pubKey}@${it.address}:${it.port}" } Logger.info("Migration peer recovery: fetched ${uris.size} peer(s) from remote backup", context = TAG) uris diff --git a/app/src/main/java/to/bitkit/services/NodeServiceFgState.kt b/app/src/main/java/to/bitkit/services/NodeServiceFgState.kt index cc69b35d84..588d892e28 100644 --- a/app/src/main/java/to/bitkit/services/NodeServiceFgState.kt +++ b/app/src/main/java/to/bitkit/services/NodeServiceFgState.kt @@ -5,9 +5,7 @@ import javax.inject.Singleton /** * Process-wide flag tracking whether [to.bitkit.androidServices.LightningNodeService] is running as a - * foreground service. Used to coordinate notification ownership: when the foreground service is alive - * it handles LDK events in-process, so the background [to.bitkit.fcm.WakeNodeWorker] defers to it - * instead of posting its own (duplicate) notification. + * foreground service. When it is alive it handles LDK events in-process. */ @Singleton class NodeServiceFgState @Inject constructor() { diff --git a/app/src/main/java/to/bitkit/services/SparkService.kt b/app/src/main/java/to/bitkit/services/SparkService.kt new file mode 100644 index 0000000000..66da5faa3a --- /dev/null +++ b/app/src/main/java/to/bitkit/services/SparkService.kt @@ -0,0 +1,328 @@ +package to.bitkit.services + +import breez_sdk_spark.AssetFilter +import breez_sdk_spark.BreezSdk +import breez_sdk_spark.ConnectRequest +import breez_sdk_spark.ConversionOptions +import breez_sdk_spark.ConversionType +import breez_sdk_spark.CrossChainConfig +import breez_sdk_spark.CrossChainRouteFilter +import breez_sdk_spark.CrossChainRoutePair +import breez_sdk_spark.DepositInfo +import breez_sdk_spark.EventListener +import breez_sdk_spark.FeePolicy +import breez_sdk_spark.FetchConversionLimitsRequest +import breez_sdk_spark.FetchConversionLimitsResponse +import breez_sdk_spark.GetInfoRequest +import breez_sdk_spark.GetInfoResponse +import breez_sdk_spark.GetTokensMetadataRequest +import breez_sdk_spark.ImportUnilateralExitStateRequest +import breez_sdk_spark.ImportUnilateralExitStateResponse +import breez_sdk_spark.InputType +import breez_sdk_spark.ListPaymentsRequest +import breez_sdk_spark.ListUnclaimedDepositsRequest +import breez_sdk_spark.MaxFee +import breez_sdk_spark.MaxOnchainFee +import breez_sdk_spark.OnchainConfirmationSpeed +import breez_sdk_spark.Payment +import breez_sdk_spark.PaymentRequest +import breez_sdk_spark.PrepareSendPaymentRequest +import breez_sdk_spark.PrepareSendPaymentResponse +import breez_sdk_spark.ReceivePaymentMethod +import breez_sdk_spark.ReceivePaymentRequest +import breez_sdk_spark.SdkEvent +import breez_sdk_spark.Seed +import breez_sdk_spark.SendPaymentMethod +import breez_sdk_spark.SendPaymentOptions +import breez_sdk_spark.SendPaymentRequest +import breez_sdk_spark.SyncWalletRequest +import breez_sdk_spark.TokenMetadata +import breez_sdk_spark.connect +import breez_sdk_spark.defaultConfig +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.lightningdevkit.ldknode.Network +import to.bitkit.data.keychain.Keychain +import to.bitkit.env.Env +import to.bitkit.models.safe +import to.bitkit.utils.AppError +import to.bitkit.utils.Logger +import to.bitkit.utils.ServiceError +import java.io.File +import java.math.BigInteger +import javax.inject.Inject +import javax.inject.Singleton +import breez_sdk_spark.Network as SparkNetwork + +/** + * Wraps the Breez SDK on Spark, which keeps Lightning available without channels after Blocktank stopped + * selling them. The Spark wallet derives its keys from the Bitkit seed on its own path (`m/8797555'`). + */ +@Singleton +class SparkService @Inject constructor( + private val keychain: Keychain, +) { + companion object { + private const val TAG = "SparkService" + + /** Leeway in sat/vB over the recommended fee that an automatic deposit claim may pay. */ + private const val DEPOSIT_CLAIM_FEE_LEEWAY_SAT_PER_VB = 2uL + + /** Share of a deposit, in parts per million, that an automatic deposit claim may pay on top. */ + private const val DEPOSIT_CLAIM_FEE_PPM = 2_000u + + private const val PPM_DIVISOR = 1_000_000uL + + /** Size of the transaction that claims a deposit, which prices the on-chain part of its fee. */ + private const val CLAIM_TX_SIZE_VBYTES = 99uL + + /** + * The most Spark may keep to claim a deposit of [amountSats] under this wallet's fee cap, at the fastest + * recommended fee rate [fastestFeeRate]. The provider quotes the real fee only once the deposit exists. + */ + fun maxDepositClaimFeeSats(fastestFeeRate: ULong, amountSats: ULong): ULong { + val rate = fastestFeeRate.safe() + DEPOSIT_CLAIM_FEE_LEEWAY_SAT_PER_VB.safe() + val onchain = rate.safe() * CLAIM_TX_SIZE_VBYTES.safe() + val proportional = amountSats.safe() * DEPOSIT_CLAIM_FEE_PPM.toULong().safe() / PPM_DIVISOR + return onchain.safe() + proportional.safe() + } + + /** + * Returns as soon as the payment starts, like ldk-node, so its completion reaches callers as an event they + * are already watching for; waiting for completion here fired that event before they watched. + */ + fun bolt11SendOptions(prefersSpark: Boolean): SendPaymentOptions = + SendPaymentOptions.Bolt11Invoice(preferSpark = prefersSpark, completionTimeoutSecs = null) + } + + private val mutex = Mutex() + private var sdk: BreezSdk? = null + private var listenerId: String? = null + + private val _events = MutableSharedFlow(extraBufferCapacity = 64) + val events: SharedFlow = _events.asSharedFlow() + + /** Spark runs on mainnet with a Breez API key, and on Lightspark's hosted regtest, which needs none. */ + val isSupported: Boolean + get() = when (Env.network) { + Network.BITCOIN -> Env.breezApiKey != null + Network.REGTEST -> true + else -> false + } + + val isConnected: Boolean get() = sdk != null + + /** + * Spark's regtest has no Lightning network, so there a Lightning invoice from another Spark wallet is paid as a + * Spark transfer, which lets two dev builds pay each other's invoices. + */ + val prefersSpark: Boolean get() = Env.network == Network.REGTEST + + suspend fun connect(walletIndex: Int) = mutex.withLock { + if (sdk != null) return@withLock + val network = sparkNetwork() ?: throw SparkError.Unsupported() + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound() + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + val config = defaultConfig(network).apply { + apiKey = Env.breezApiKey + maxDepositClaimFee = MaxFee( + onchain = MaxOnchainFee.NetworkRecommended(DEPOSIT_CLAIM_FEE_LEEWAY_SAT_PER_VB), + proportionalPpm = DEPOSIT_CLAIM_FEE_PPM, + ) + if (network == SparkNetwork.MAINNET) crossChainConfig = CrossChainConfig() + } + val storageDir = File(Env.sparkStoragePath(walletIndex)).apply { mkdirs() }.path + val connected = connect(ConnectRequest(config, Seed.Mnemonic(mnemonic, passphrase), storageDir)) + listenerId = connected.addEventListener( + object : EventListener { + override suspend fun onEvent(event: SdkEvent) { + _events.emit(event) + } + }, + ) + sdk = connected + Logger.info("Connected Spark wallet on '$network'", context = TAG) + } + + suspend fun disconnect() = mutex.withLock { + val current = sdk ?: return@withLock + listenerId?.let { current.removeEventListener(it) } + current.disconnect() + sdk = null + listenerId = null + Logger.info("Disconnected Spark wallet", context = TAG) + } + + suspend fun wipeStorage(walletIndex: Int) { + disconnect() + File(Env.sparkStoragePath(walletIndex)).deleteRecursively() + } + + /** Syncs with Spark now instead of waiting for the SDK's next background sync. */ + suspend fun syncWallet() { + requireSdk().syncWallet(SyncWalletRequest) + } + + suspend fun getInfo(): GetInfoResponse = requireSdk().getInfo(GetInfoRequest(ensureSynced = false)) + + suspend fun receiveBolt11(amountSats: ULong?, description: String, expirySecs: UInt): String { + val method = ReceivePaymentMethod.Bolt11Invoice(description, amountSats, expirySecs, null, null) + return requireSdk().receivePayment(ReceivePaymentRequest(method)).paymentRequest + } + + /** Returns the static on-chain address that funds the Spark wallet, claimed automatically once confirmed. */ + suspend fun depositAddress(): String { + val method = ReceivePaymentMethod.BitcoinAddress(newAddress = null) + return requireSdk().receivePayment(ReceivePaymentRequest(method)).paymentRequest + } + + suspend fun prepareSend( + paymentRequest: String, + amountSats: ULong?, + feePolicy: FeePolicy? = null, + ): PrepareSendPaymentResponse = + requireSdk().prepareSendPayment( + PrepareSendPaymentRequest( + paymentRequest = PaymentRequest.Input(input = paymentRequest), + amount = amountSats?.let { BigInteger.valueOf(it.toLong()) }, + tokenIdentifier = null, + conversionOptions = null, + feePolicy = feePolicy, + ), + ) + + suspend fun send(prepared: PrepareSendPaymentResponse): Payment { + val options = when (prepared.paymentMethod) { + is SendPaymentMethod.Bolt11Invoice -> bolt11SendOptions(prefersSpark) + is SendPaymentMethod.BitcoinAddress -> SendPaymentOptions.BitcoinAddress(OnchainConfirmationSpeed.MEDIUM) + else -> null + } + return requireSdk().sendPayment(SendPaymentRequest(prepared, options)).payment + } + + /** The fastest recommended fee rate, in sat/vB, from the SDK's own fee source. */ + suspend fun fastestFeeRate(): ULong = requireSdk().recommendedFees().fastestFee + + suspend fun listUnclaimedDeposits(): List = + requireSdk().listUnclaimedDeposits(ListUnclaimedDepositsRequest).deposits + + suspend fun listPayments(): List = requireSdk().listPayments(ListPaymentsRequest()).payments + + suspend fun listTokenPayments(tokenId: String): List = + requireSdk().listPayments(ListPaymentsRequest(assetFilter = AssetFilter.Token(tokenId))).payments + + /** + * A Spark invoice for [tokenId], so a payer scanning it knows which token to send; the wallet's plain Spark + * address receives tokens too but doesn't name one. + */ + suspend fun receiveTokenInvoice(tokenId: String, amount: BigInteger?): String { + val method = ReceivePaymentMethod.SparkInvoice( + amount = amount, + tokenIdentifier = tokenId, + expiryTime = null, + description = null, + senderPublicKey = null, + ) + return requireSdk().receivePayment(ReceivePaymentRequest(method)).paymentRequest + } + + suspend fun tokenMetadata(tokenId: String): TokenMetadata? = + requireSdk().getTokensMetadata(GetTokensMetadataRequest(listOf(tokenId))).tokensMetadata.firstOrNull() + + /** The minimum amounts Flashnet converts, for [type] and the dollar token [tokenId]. */ + suspend fun conversionLimits(type: ConversionType, tokenId: String): FetchConversionLimitsResponse = + requireSdk().fetchConversionLimits(FetchConversionLimitsRequest(type, tokenId)) + + /** + * Prepares a conversion within this wallet: a payment to its own Spark address, which the SDK runs as a + * self-transfer through Flashnet. [amount] is in token units both ways: what to get when converting to the token, + * what to give when converting to bitcoin. + */ + suspend fun prepareConversion(toToken: Boolean, amount: BigInteger, tokenId: String): PrepareSendPaymentResponse { + val sdk = requireSdk() + val ownAddress = sdk.receivePayment(ReceivePaymentRequest(ReceivePaymentMethod.SparkAddress)).paymentRequest + val type = if (toToken) ConversionType.FromBitcoin else ConversionType.ToBitcoin(tokenId) + return sdk.prepareSendPayment( + PrepareSendPaymentRequest( + paymentRequest = PaymentRequest.Input(input = ownAddress), + amount = amount, + tokenIdentifier = tokenId, + conversionOptions = ConversionOptions(conversionType = type), + feePolicy = FeePolicy.FEES_INCLUDED.takeIf { !toToken }, + ), + ) + } + + /** Routes that pay [address], an address on another chain, through a cross-chain provider. */ + suspend fun crossChainRoutes(address: String): List { + val details = (requireSdk().parse(address) as? InputType.CrossChainAddress)?.v1 + ?: throw SparkError.NotCrossChainAddress() + return requireSdk().getCrossChainRoutes(CrossChainRouteFilter.Send(details, null)) + } + + /** Prepares paying [amount] token units of [tokenId] to [address] on another chain through [route]. */ + suspend fun prepareCrossChain( + address: String, + route: CrossChainRoutePair, + amount: BigInteger, + tokenId: String, + ): PrepareSendPaymentResponse = requireSdk().prepareSendPayment( + PrepareSendPaymentRequest( + paymentRequest = PaymentRequest.CrossChain( + address = address, + route = route, + maxSlippageBps = null, + targetOverpayBps = null + ), + amount = amount, + tokenIdentifier = tokenId, + conversionOptions = null, + feePolicy = null, + ), + ) + + /** The wallet's static Spark address; it receives sats and tokens alike. */ + suspend fun sparkAddress(): String = + requireSdk().receivePayment(ReceivePaymentRequest(ReceivePaymentMethod.SparkAddress)).paymentRequest + + suspend fun parse(input: String): InputType = requireSdk().parse(input) + + /** Prepares a [tokenId] payment; [amount] in token base units, ignored by the SDK when an invoice sets one. */ + suspend fun prepareTokenSend(paymentRequest: String, amount: BigInteger?, tokenId: String) = + requireSdk().prepareSendPayment( + PrepareSendPaymentRequest( + paymentRequest = PaymentRequest.Input(input = paymentRequest), + amount = amount, + tokenIdentifier = tokenId, + conversionOptions = null, + feePolicy = null, + ), + ) + + /** Exports what a unilateral exit needs if the Spark operators stop cooperating; it can reach several MB. */ + suspend fun exportExitState(): String = requireSdk().exportUnilateralExitState().exitState + + /** Merges an exported exit state back in, without contacting the Spark operators. */ + suspend fun importExitState(exitState: String): ImportUnilateralExitStateResponse = + requireSdk().importUnilateralExitState(ImportUnilateralExitStateRequest(exitState)) + + /** The Spark network this build runs on, as exit data backups record it. */ + val networkName: String? get() = sparkNetwork()?.name?.lowercase() + + private fun requireSdk(): BreezSdk = sdk ?: throw SparkError.NotConnected() + + private fun sparkNetwork(): SparkNetwork? = when (Env.network) { + Network.BITCOIN if Env.breezApiKey != null -> SparkNetwork.MAINNET + Network.REGTEST -> SparkNetwork.REGTEST + else -> null + } +} + +sealed class SparkError(message: String) : AppError(message) { + class Unsupported : SparkError("Spark is not available on this network or build") + class NotConnected : SparkError("Spark wallet is not connected") + class NotCrossChainAddress : SparkError("Not an address on a chain the cross-chain providers serve") +} diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index fff4a35de1..e20729cd8d 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -170,6 +170,8 @@ import to.bitkit.ui.screens.wallets.receive.ReceiveRoute import to.bitkit.ui.screens.wallets.receive.ReceiveSheet import to.bitkit.ui.screens.wallets.send.HwSendViewModel import to.bitkit.ui.screens.wallets.suggestion.BuyIntroScreen +import to.bitkit.ui.screens.wallets.usdt.SparkUsdActivityScreen +import to.bitkit.ui.screens.wallets.usdt.SparkUsdWalletScreen import to.bitkit.ui.screens.wallets.usdt.UsdtDepositsScreen import to.bitkit.ui.screens.wallets.usdt.UsdtWalletScreen import to.bitkit.ui.screens.widgets.WidgetsIntroScreen @@ -203,6 +205,7 @@ import to.bitkit.ui.settings.lightning.ChannelDetailScreen import to.bitkit.ui.settings.lightning.CloseConnectionScreen import to.bitkit.ui.settings.lightning.LightningConnectionsScreen import to.bitkit.ui.settings.lightning.LightningConnectionsViewModel +import to.bitkit.ui.settings.lightning.SparkDetailScreen import to.bitkit.ui.settings.pin.PinManagementScreen import to.bitkit.ui.settings.quickPay.QuickPayIntroScreen import to.bitkit.ui.settings.quickPay.QuickPaySettingsScreen @@ -226,6 +229,7 @@ import to.bitkit.ui.sheets.PinSheet import to.bitkit.ui.sheets.QrScanningSheet import to.bitkit.ui.sheets.QuickPayIntroSheet import to.bitkit.ui.sheets.SendSheet +import to.bitkit.ui.sheets.TransferUnavailableSheet import to.bitkit.ui.sheets.UpdateSheet import to.bitkit.ui.sheets.UsdtIntroSheet import to.bitkit.ui.sheets.WidgetsSheet @@ -656,6 +660,12 @@ fun ContentView( Sheet.ConnectionClosed -> ConnectionClosedSheet( onDismiss = { appViewModel.hideSheet() }, ) + Sheet.TransferUnavailable -> TransferUnavailableSheet( + onContinue = { + navController.navigateTo(ExternalConnection()) + appViewModel.hideSheet() + }, + ) is Sheet.BTCPayConnection -> BTCPayConnectionSheet(sheet, appViewModel) is Sheet.Gift -> GiftSheet(sheet, appViewModel) @@ -884,6 +894,8 @@ private fun RootNavHost( transferViewModel.transferEffects.collect { effect -> if (effect is TransferEffect.OnFundingPending) { appViewModel.showPendingTransfer(effect.attempt) + } else if (effect is TransferEffect.OnTransferUnavailable) { + appViewModel.showSheet(Sheet.TransferUnavailable) } else { navController.navigateForTransferEffect(effect) } @@ -1138,6 +1150,8 @@ private fun RootNavHost( deepLinkableComposable { val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() + val sparkState by walletViewModel.sparkState.collectAsStateWithLifecycle() + val isTransferUnavailable = isGeoBlocked && !sparkState.isReady FundingScreen( onTransfer = { @@ -1152,7 +1166,7 @@ private fun RootNavHost( }, onManual = { navController.navigateTo(Routes.ExternalNav) }, onBackClick = { navController.popBackStack() }, - isGeoBlocked = isGeoBlocked, + isGeoBlocked = isTransferUnavailable, ) } deepLinkableComposable { @@ -1283,6 +1297,18 @@ private fun NavGraphBuilder.home( deepLinkableComposable { UsdtWalletScreen(onBack = { navController.popBackStack() }) } + composableWithDefaultTransitions { entry -> + SparkUsdActivityScreen( + transferId = entry.toRoute().transferId, + onBack = { navController.popBackStack() }, + ) + } + composableWithDefaultTransitions { entry -> + SparkUsdWalletScreen( + onBack = { navController.popBackStack() }, + initialRecipient = entry.toRoute().recipient, + ) + } composableWithDefaultTransitions { entry -> val payment = entry.toRoute() UsdtWalletScreen( @@ -1294,17 +1320,22 @@ private fun NavGraphBuilder.home( deepLinkableComposable { val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() + val sparkState by walletViewModel.sparkState.collectAsStateWithLifecycle() + val isTransferUnavailable = isGeoBlocked && !sparkState.isReady val onchainActivities by activityListViewModel.onchainActivities.collectAsStateWithLifecycle() val forceCloseRemainingDuration by appViewModel.forceCloseRemainingDuration.collectAsStateWithLifecycle() SavingsWalletScreen( - isGeoBlocked = isGeoBlocked, onchainActivities = onchainActivities ?: persistentListOf(), onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, onActivityItemClick = { navController.navToActivityDetail(it) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, onTransferToSpendingClick = { - navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) + if (isTransferUnavailable) { + appViewModel.showSheet(Sheet.TransferUnavailable) + } else { + navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) + } }, onBackClick = { navController.popBackStack() }, forceCloseRemainingDuration = forceCloseRemainingDuration, @@ -1313,11 +1344,15 @@ private fun NavGraphBuilder.home( deepLinkableComposable { val hasSeenSavingsIntro by settingsViewModel.hasSeenSavingsIntro.collectAsStateWithLifecycle() val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() + val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() + val sparkState by walletViewModel.sparkState.collectAsStateWithLifecycle() + val isTransferUnavailable = isGeoBlocked && !sparkState.isReady val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() val lightningActivities by activityListViewModel.lightningActivities.collectAsStateWithLifecycle() SpendingWalletScreen( channels = lightningState.channels, + hasSparkBalance = sparkState.balanceSats > 0uL, lightningActivities = lightningActivities ?: persistentListOf(), onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, onActivityItemClick = { navController.navToActivityDetail(it) }, @@ -1330,7 +1365,11 @@ private fun NavGraphBuilder.home( } }, onTransferFromSavingsClick = { - navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) + if (isTransferUnavailable) { + appViewModel.showSheet(Sheet.TransferUnavailable) + } else { + navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) + } }, onBackClick = { navController.popBackStack() }, ) @@ -1338,11 +1377,18 @@ private fun NavGraphBuilder.home( deepLinkableComposable { val walletId = it.toRoute().walletId val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() + val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() + val sparkState by walletViewModel.sparkState.collectAsStateWithLifecycle() + val isTransferUnavailable = isGeoBlocked && !sparkState.isReady HardwareWalletScreen( walletId = walletId, onActivityItemClick = { navController.navToActivityDetail(it) }, onTransferToSpendingClick = { selectedDeviceId -> - navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, selectedDeviceId) + if (isTransferUnavailable) { + appViewModel.showSheet(Sheet.TransferUnavailable) + } else { + navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, selectedDeviceId) + } }, onBackClick = { navController.popBackStack() }, ) @@ -1932,6 +1978,20 @@ private fun NavGraphBuilder.lightningConnections( navController = navController, ) } + deepLinkableComposable { + val hasSeenSavingsIntro = settingsViewModel?.hasSeenSavingsIntro + ?.collectAsStateWithLifecycle()?.value == true + SparkDetailScreen( + onBack = { navController.popBackStack() }, + onTransferToSavings = { + if (hasSeenSavingsIntro) { + navController.navigateToTransferSavingsAvailability() + } else { + navController.navigateToTransferSavingsIntro() + } + }, + ) + } deepLinkableComposable { val route = it.toRoute() CloseConnectionScreen( @@ -2320,6 +2380,12 @@ sealed interface Routes { @Serializable data class UsdtPayment(val recipient: String, val amount: String) : Routes.InternalOnly + @Serializable + data class SparkUsdWallet(val recipient: String = "") : Routes.InternalOnly + + @Serializable + data class SparkUsdActivity(val transferId: String) : Routes.InternalOnly + @Serializable data object Settings : Routes.DeepLinkable @@ -2412,6 +2478,9 @@ sealed interface Routes { @Serializable data class ChannelDetail(val channelId: String) : Routes.DeepLinkable + @Serializable + data object SparkDetail : Routes.DeepLinkable + @Serializable data class CloseConnection(val channelId: String) : Routes.DeepLinkable diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 2f2b47d664..b98222b557 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -348,6 +348,8 @@ class MainActivity : FragmentActivity() { override fun onResume() { super.onResume() appViewModel.onAppResumed() + // A permission dialog launched from a sheet that has since closed loses its result callback. + settingsViewModel.setNotificationPreference(areNotificationsEnabled()) } override fun onStop() { diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt index 814325d29e..9b8eec4123 100644 --- a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt +++ b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt @@ -42,13 +42,13 @@ import org.lightningdevkit.ldknode.LightningBalance import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.R -import to.bitkit.env.Peers import to.bitkit.ext.amountSats import to.bitkit.ext.balanceUiText import to.bitkit.ext.channelId import to.bitkit.ext.createChannelDetails import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.formatToString +import to.bitkit.ext.of import to.bitkit.ext.uri import to.bitkit.models.NodeLifecycleState import to.bitkit.models.NodePeer @@ -476,10 +476,10 @@ private fun ChannelDetailRow( private fun previewPeers() = persistentListOf( NodePeer( - peerDetails = Peers.stag, + peerDetails = previewPeer, lspNode = ILspNode( alias = "Blocktank-LND1", - pubkey = Peers.stag.nodeId, + pubkey = previewPeer.nodeId, connectionStrings = listOf(), readonly = null, ), @@ -532,7 +532,7 @@ private fun Preview() { latestPathfindingScoresSyncTimestamp = null, ), nodeId = "0348a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", - peers = persistentListOf(Peers.stag), + peers = persistentListOf(previewPeer), channels = persistentListOf( createChannelDetails().copy( channelId = "abc123def456789012345678901234567890123456789012345678901234567890", @@ -600,3 +600,7 @@ private fun Preview() { ) } } + +private val previewPeer = PeerDetails.of( + "0248a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9@127.0.0.1:9735" +) diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoViewModel.kt b/app/src/main/java/to/bitkit/ui/NodeInfoViewModel.kt index 6eaf854415..1dae06ace5 100644 --- a/app/src/main/java/to/bitkit/ui/NodeInfoViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/NodeInfoViewModel.kt @@ -18,7 +18,6 @@ import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.LightningBalance import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.R -import to.bitkit.env.Peers import to.bitkit.models.NodePeer import to.bitkit.models.Toast import to.bitkit.models.alias @@ -47,7 +46,6 @@ class NodeInfoViewModel @Inject constructor( NodePeer( peerDetails = peer, lspNode = lspNodes?.firstOrNull { it.pubkey == peer.nodeId }, - name = Peers.Known.find(peer)?.name, ) }.sortedBy { it.alias() }.toImmutableList() } diff --git a/app/src/main/java/to/bitkit/ui/components/LightningChannel.kt b/app/src/main/java/to/bitkit/ui/components/LightningChannel.kt index 2b604ebe71..bdcb5d046b 100644 --- a/app/src/main/java/to/bitkit/ui/components/LightningChannel.kt +++ b/app/src/main/java/to/bitkit/ui/components/LightningChannel.kt @@ -39,8 +39,10 @@ fun LightningChannel( modifier: Modifier = Modifier, status: ChannelStatusUi = ChannelStatusUi.PENDING, showLabels: Boolean = false, + showReceiving: Boolean = true, ) { - val spendingColor = if (status == ChannelStatusUi.CLOSED) Colors.Gray5 else Colors.Purple50 + val isEmptySpendingBar = !showReceiving && localBalance == 0L + val spendingColor = if (status == ChannelStatusUi.CLOSED || isEmptySpendingBar) Colors.Gray5 else Colors.Purple50 val spendingAvailableColor = if (status == ChannelStatusUi.CLOSED) Colors.Gray3 else Colors.Purple val receivingColor = if (status == ChannelStatusUi.CLOSED) Colors.Gray5 else Colors.White64 val receivingAvailableColor = if (status == ChannelStatusUi.CLOSED) Colors.Gray3 else Colors.White @@ -58,7 +60,9 @@ fun LightningChannel( modifier = Modifier.fillMaxWidth(), ) { Caption13Up(text = stringResource(R.string.lightning__spending_label), color = Colors.White64) - Caption13Up(text = stringResource(R.string.lightning__receiving_label), color = Colors.White64) + if (showReceiving) { + Caption13Up(text = stringResource(R.string.lightning__receiving_label), color = Colors.White64) + } } Spacer(modifier = Modifier.height(8.dp)) } @@ -78,17 +82,19 @@ fun LightningChannel( color = spendingAvailableColor, ) } - Row(verticalAlignment = CenterVertically) { - Icon( - painter = painterResource(R.drawable.ic_received), - contentDescription = null, - tint = receivingAvailableColor, - modifier = Modifier.size(14.dp) - ) - MoneyCaptionB( - sats = remoteBalance, - color = receivingAvailableColor, - ) + if (showReceiving) { + Row(verticalAlignment = CenterVertically) { + Icon( + painter = painterResource(R.drawable.ic_received), + contentDescription = null, + tint = receivingAvailableColor, + modifier = Modifier.size(14.dp) + ) + MoneyCaptionB( + sats = remoteBalance, + color = receivingAvailableColor, + ) + } } } Spacer(modifier = Modifier.height(8.dp)) @@ -102,16 +108,17 @@ fun LightningChannel( modifier = Modifier .weight(1f) .fillMaxHeight() - .background(spendingColor, RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp)) + .background(spendingColor, if (showReceiving) SpendingHalfShape else FullBarShape) ) { Box( modifier = Modifier .fillMaxHeight() .fillMaxWidth(spendingAvailableFraction) .align(Alignment.CenterEnd) - .background(spendingAvailableColor, RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp)) + .background(spendingAvailableColor, if (showReceiving) SpendingHalfShape else FullBarShape) ) } + if (!showReceiving) return@Row Spacer(modifier = Modifier.width(4.dp)) Box( modifier = Modifier @@ -130,6 +137,9 @@ fun LightningChannel( } } +private val SpendingHalfShape = RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp) +private val FullBarShape = RoundedCornerShape(8.dp) + // region preview @Preview(showBackground = true) diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index ff1b102332..18c366380e 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -98,6 +98,7 @@ sealed interface Sheet { data object ForceTransfer : Sheet data class Gift(val code: String, val amount: ULong) : Sheet data object ConnectionClosed : Sheet + data object TransferUnavailable : Sheet data class BTCPayConnection( val setup: SamRockSetupRequest, val isConnecting: Boolean = false, diff --git a/app/src/main/java/to/bitkit/ui/components/SheetIntro.kt b/app/src/main/java/to/bitkit/ui/components/SheetIntro.kt index a4a029e3b9..22face51ba 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetIntro.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetIntro.kt @@ -38,6 +38,7 @@ fun SheetIntro( continueLoading: Boolean = false, cancelText: String? = null, onCancel: (() -> Unit)? = null, + onBack: (() -> Unit)? = null, testTag: String = "SheetIntro", cancelTestTag: String = "${testTag}Cancel", continueTestTag: String = "${testTag}Continue", @@ -49,7 +50,7 @@ fun SheetIntro( .navigationBarsPadding() .testTag(testTag) ) { - SheetTopBar(navTitle) + SheetTopBar(navTitle, onBack = onBack) Column( modifier = Modifier.padding(horizontal = 32.dp), diff --git a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt index 657fec0fc4..68d5499331 100644 --- a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt @@ -241,21 +241,6 @@ fun DevSettingsScreen( viewModel.openChannel() } ) - - SectionHeader("NOTIFICATIONS") - - SettingsTextButtonRow( - title = "Register For LSP Notifications", - onClick = { - viewModel.registerForNotifications() - } - ) - SettingsTextButtonRow( - title = "Test LSP Notification", - onClick = { - viewModel.testLspNotification() - } - ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt index 6bc695b0fa..fc91ca97dc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt @@ -68,6 +68,7 @@ import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.rememberSubscriptionNotificationsRequest import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.AppViewModel import kotlin.time.Duration.Companion.seconds @@ -85,6 +86,7 @@ fun AllowanceSheet( viewModel: AllowancesViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val requestNotifications = rememberSubscriptionNotificationsRequest() // The view model outlives this sheet, so a check left open must never run in the next one. DisposableEffect(route) { onDispose { viewModel.onAuthorizationCancelled() } @@ -128,7 +130,10 @@ fun AllowanceSheet( allowance = allowance, isWorking = uiState.isWorking, onClickDecline = { viewModel.decline(allowance.id, onSuccess = appViewModel::hideSheet) }, - onClickAccept = { viewModel.accept(allowance.id, onSuccess = appViewModel::hideSheet) }, + onClickAccept = { + requestNotifications() + viewModel.accept(allowance.id, onSuccess = appViewModel::hideSheet) + }, modifier = Modifier.sheetHeight() ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt index 21591fec10..8896558436 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt @@ -110,6 +110,7 @@ import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.rememberSubscriptionNotificationsRequest import to.bitkit.ui.utils.withAccent import to.bitkit.utils.SubscriptionClockOffset import to.bitkit.utils.SubscriptionIcon @@ -127,6 +128,7 @@ fun CreateSubscriptionSheet( val resources = LocalResources.current val currencies = LocalCurrencies.current val scope = rememberCoroutineScope() + val requestNotifications = rememberSubscriptionNotificationsRequest() val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() val targets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() val isCreating by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() @@ -242,6 +244,7 @@ fun CreateSubscriptionSheet( onSelected = { selectedTarget = it }, onExpirationChange = { expiration = it }, onPropose = { + requestNotifications() selectedTarget?.let { target -> appViewModel.createSubscription( draft = PaykitSubscriptionDraft( diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt index a76e5a551a..70a851e4cb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt @@ -118,6 +118,7 @@ import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.sheets.TransactionConfetti import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.rememberSubscriptionNotificationsRequest import to.bitkit.ui.utils.removeAccentTags import to.bitkit.ui.utils.withAccent import to.bitkit.utils.SubscriptionClockOffset @@ -697,6 +698,7 @@ fun SubscriptionSheet(appViewModel: AppViewModel, initialRoute: SubscriptionRout val subscription = subscriptions.firstOrNull { it.id == route.id } val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() val now = rememberSubscriptionNow(listOfNotNull(subscription).toImmutableList()) + val requestNotifications = rememberSubscriptionNotificationsRequest() LaunchedEffect(route, subscription, isProcessing) { val proposalIsUnavailable = route is SubscriptionRoute.Review && @@ -734,6 +736,7 @@ fun SubscriptionSheet(appViewModel: AppViewModel, initialRoute: SubscriptionRout } }, onSubscribe = { + requestNotifications() isProcessing = true appViewModel.acceptSubscriptionAndStartPayment(subscription).fold( onSuccess = { startedPayment -> diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt index 39f276e3b0..1ff681448e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.screens.transfer +import android.content.Intent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,16 +14,22 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import to.bitkit.R import to.bitkit.env.Defaults +import to.bitkit.env.Env import to.bitkit.ui.LocalBalances import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.RectangleButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon @@ -62,7 +69,7 @@ fun FundingScreen( Spacer(modifier = Modifier.height(8.dp)) val text = if (isGeoBlocked) { - stringResource(R.string.lightning__funding__text_blocked) + stringResource(R.string.lightning__transfer_unavailable__text) } else { stringResource(R.string.lightning__funding__text) } @@ -70,9 +77,20 @@ fun FundingScreen( Spacer(modifier = Modifier.height(32.dp)) + val manualButton = @Composable { + RectangleButton( + label = stringResource(R.string.lightning__funding_advanced__button2), + icon = R.drawable.ic_pencil_full, + iconTint = Colors.Purple, + iconSize = 13.37.dp, + onClick = onManual, + modifier = Modifier.testTag("FundManual") + ) + } Column( verticalArrangement = Arrangement.spacedBy(8.dp), ) { + if (isGeoBlocked) manualButton() Box { RectangleButton( label = stringResource(R.string.lightning__funding__button1), @@ -87,12 +105,23 @@ fun FundingScreen( .matchParentSize() .clickableAlpha( pressedAlpha = 1f, - enabled = balances.channelFundableBalance == 0uL, + enabled = balances.channelFundableBalance == 0uL && !isGeoBlocked, onClick = { showNoFundsAlert = true }, ) .testTag("FundTransfer") ) } + if (isGeoBlocked) { + RectangleButton( + label = stringResource(R.string.lightning__funding__button_hw), + icon = R.drawable.ic_device_tablet_speaker, + iconTint = Colors.Purple, + iconSize = 16.25.dp, + enabled = false, + onClick = {}, + modifier = Modifier.testTag("FundHardware") + ) + } RectangleButton( label = stringResource(R.string.lightning__funding__button2), icon = R.drawable.ic_qr_purple, @@ -102,16 +131,23 @@ fun FundingScreen( onClick = onFund, modifier = Modifier.testTag("FundReceive") ) - RectangleButton( - label = stringResource(R.string.lightning__funding_advanced__button2), - icon = R.drawable.ic_pencil_full, - iconTint = Colors.Purple, - iconSize = 13.37.dp, - onClick = onManual, - modifier = Modifier.testTag("FundManual") - ) + if (!isGeoBlocked) manualButton() } } + if (isGeoBlocked) { + val context = LocalContext.current + FillHeight() + SecondaryButton( + text = stringResource(R.string.common__learn_more), + onClick = { + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Env.BLOCKTANK_SUNSET_URL.toUri())) } + }, + modifier = Modifier + .padding(horizontal = 16.dp) + .testTag("FundLearnMore") + ) + VerticalSpacer(16.dp) + } if (showNoFundsAlert) { AppAlertDialog( title = stringResource(R.string.lightning__no_funds__title), @@ -135,3 +171,11 @@ private fun FundingScreenPreview() { FundingScreen(isGeoBlocked = false) } } + +@Preview(showSystemUi = true) +@Composable +private fun FundingScreenGeoBlockedPreview() { + AppThemeSurface { + FundingScreen(isGeoBlocked = true) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt index 4b18c10d88..d76fd1a4ca 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt @@ -86,7 +86,9 @@ fun SavingsConfirmScreen( val channels = selectedChannels ?: openChannels - val amount = channels.sumOf { it.amountOnClose } + val sparkState by wallet.sparkState.collectAsStateWithLifecycle() + val channelsAmount = channels.sumOf { it.amountOnClose } + val amount = channelsAmount + sparkState.balanceSats val swapState by transfer.savingsSwapState.collectAsStateWithLifecycle() @@ -96,8 +98,8 @@ fun SavingsConfirmScreen( } // Present the swap fee before the user commits. Recomputed when the amount changes. - LaunchedEffect(amount) { - if (amount > 0uL) transfer.loadSavingsSwapQuote(amount) + LaunchedEffect(channelsAmount) { + if (channelsAmount > 0uL) transfer.loadSavingsSwapQuote(channelsAmount) } Box { diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt index d408960dba..b055ff86cb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt @@ -76,14 +76,6 @@ fun SavingsProgressScreen( onSuccess = { progressState = SavingsProgressState.SUCCESS }, onInterrupted = { progressState = SavingsProgressState.INTERRUPTED }, onGiveUp = { app.showSheet(Sheet.ForceTransfer) }, - onUnavailable = { - app.toast( - type = Toast.ToastType.ERROR, - title = resources.getString(R.string.lightning__close_error), - description = resources.getString(R.string.lightning__close_error_msg), - ) - onTransferUnavailable() - }, ) } } @@ -130,32 +122,23 @@ private suspend fun runChannelClose( onSuccess: () -> Unit, onInterrupted: () -> Unit, onGiveUp: () -> Unit, - onUnavailable: () -> Unit, ) { + val closesChannels = transfer.hasChannelsToClose() val channelsFailedToCoopClose = transfer.closeSelectedChannels() if (channelsFailedToCoopClose.isEmpty()) { wallet.refreshState() - delay(5000) + if (closesChannels) delay(5000) onSuccess() return } - // Check if any channels can be retried (filter out trusted peers) - val (_, nonTrustedChannels) = transfer.separateTrustedChannels(channelsFailedToCoopClose) - - if (nonTrustedChannels.isEmpty()) { - // All channels are trusted peers - show error and navigate back immediately - onUnavailable() - } else { - transfer.startCoopCloseRetries( - channels = nonTrustedChannels, - onGiveUp = onGiveUp, - onTransferUnavailable = onUnavailable, - ) - delay(2500) - onInterrupted() - } + transfer.startCoopCloseRetries( + channels = channelsFailedToCoopClose, + onGiveUp = onGiveUp, + ) + delay(2500) + onInterrupted() } @Composable diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt index 9567fdbbd1..3d0c4afbae 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt @@ -180,7 +180,16 @@ private fun Content( .verticalScroll(rememberScrollState()) ) { val clientBalance = state.clientBalanceSat - val lspFee = state.feeSat.safe() - clientBalance.safe() + val lspFee = if (state.isSpark) { + state.sparkClaimFeeSats + } else { + state.feeSat.safe() - clientBalance.safe() + } + val toSpending = if (state.isSpark) { + clientBalance.safe() - state.sparkClaimFeeSats.safe() + } else { + clientBalance + } val total = state.confirmLeavingAmountSats val lspBalance = state.lspBalanceSat @@ -197,7 +206,13 @@ private fun Content( amount = miningFeeSats.toLong(), ) FeeInfo( - label = stringResource(R.string.lightning__spending_confirm__lsp_fee), + label = stringResource( + if (state.isSpark) { + R.string.lightning__spark_claim_fee + } else { + R.string.lightning__spending_confirm__lsp_fee + } + ), amount = lspFee.toLong(), ) } @@ -207,7 +222,7 @@ private fun Content( ) { FeeInfo( label = stringResource(R.string.lightning__spending_confirm__amount), - amount = clientBalance.toLong(), + amount = toSpending.toLong(), ) FeeInfo( label = stringResource(R.string.lightning__spending_confirm__total), @@ -229,42 +244,49 @@ private fun Content( VerticalSpacer(16.dp) } - SettingsSwitchRow( - title = stringResource(R.string.settings__bg__setup), - isChecked = hasNotificationPermission, - colors = AppSwitchDefaults.colorsPurple, - onClick = onSwitchClick, - switchTestTag = "SpendingConfirmNotificationSwitch", - modifier = Modifier.fillMaxWidth() - ) + // Blocktank's order-paid push and channel liquidity don't apply to funding Spark. + if (!state.isSpark) { + SettingsSwitchRow( + title = stringResource(R.string.settings__bg__setup), + isChecked = hasNotificationPermission, + colors = AppSwitchDefaults.colorsPurple, + onClick = onSwitchClick, + switchTestTag = "SpendingConfirmNotificationSwitch", + modifier = Modifier.fillMaxWidth() + ) - VerticalSpacer(16.dp) + VerticalSpacer(16.dp) - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - PrimaryButton( - text = stringResource(R.string.common__learn_more), - size = ButtonSize.Small, - fullWidth = false, - onClick = onLearnMoreClick, - modifier = Modifier.testTag("SpendingConfirmMore") - ) - PrimaryButton( - text = stringResource( - if (isAdvanced) R.string.lightning__spending_confirm__default else R.string.common__advanced - ), - size = ButtonSize.Small, - fullWidth = false, - onClick = { - if (isAdvanced) { - onUseDefaultLspBalanceClick() - } else { - onAdvancedClick() - } - }, - modifier = Modifier.testTag( - if (isAdvanced) "SpendingConfirmDefault" else "SpendingConfirmAdvanced" + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + PrimaryButton( + text = stringResource(R.string.common__learn_more), + size = ButtonSize.Small, + fullWidth = false, + onClick = onLearnMoreClick, + modifier = Modifier.testTag("SpendingConfirmMore") ) - ) + PrimaryButton( + text = stringResource( + if (isAdvanced) { + R.string.lightning__spending_confirm__default + } else { + R.string.common__advanced + } + ), + size = ButtonSize.Small, + fullWidth = false, + onClick = { + if (isAdvanced) { + onUseDefaultLspBalanceClick() + } else { + onAdvancedClick() + } + }, + modifier = Modifier.testTag( + if (isAdvanced) "SpendingConfirmDefault" else "SpendingConfirmAdvanced" + ) + ) + } } VerticalSpacer(16.dp) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelScreen.kt index 5f6a13d4b0..f6699d04d4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelScreen.kt @@ -22,9 +22,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.R -import to.bitkit.env.Peers import to.bitkit.ext.host +import to.bitkit.ext.of import to.bitkit.ext.port import to.bitkit.ui.Routes import to.bitkit.ui.components.BodyM @@ -173,7 +174,7 @@ private fun InfoRow( private fun Preview() { AppThemeSurface { Content( - uiState = LnurlChannelUiState(peer = Peers.stag), + uiState = LnurlChannelUiState(peer = previewPeer), ) } } @@ -187,3 +188,7 @@ private fun PreviewLoading() { ) } } + +private val previewPeer = PeerDetails.of( + "0248a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9@127.0.0.1:9735" +) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index 5c75d59a64..9c1177c2c2 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -174,6 +174,7 @@ import to.bitkit.ui.navigateToTransferIntro import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.screens.wallets.activity.components.ActivityListSimple import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems +import to.bitkit.ui.screens.wallets.usdt.SparkUsdWalletBalance import to.bitkit.ui.screens.wallets.usdt.UsdtRefreshEffect import to.bitkit.ui.screens.wallets.usdt.UsdtViewModel import to.bitkit.ui.screens.wallets.usdt.UsdtWalletBalance @@ -208,6 +209,7 @@ import to.bitkit.ui.theme.Colors import to.bitkit.ui.theme.Insets import to.bitkit.ui.theme.TopBarGradient import to.bitkit.ui.utils.withAccent +import to.bitkit.ui.walletViewModel import to.bitkit.viewmodels.ActivityListViewModel import to.bitkit.viewmodels.AppViewModel import to.bitkit.viewmodels.SettingsViewModel @@ -409,6 +411,7 @@ fun HomeScreen( onNavigateToSpending = { walletNavController.navigate(Routes.Spending) }, onClickHardwareWallet = { walletNavController.navigateTo(Routes.HardwareWallet(it)) }, onClickUsdt = { walletNavController.navigateTo(Routes.UsdtWallet) }, + onClickSparkUsd = { walletNavController.navigateTo(Routes.SparkUsdWallet()) }, onCalculatorInputActiveChanged = onCalculatorInputActiveChanged, ) } @@ -450,6 +453,7 @@ private fun Content( onNavigateToSpending: () -> Unit = {}, onClickHardwareWallet: (String) -> Unit = {}, onClickUsdt: () -> Unit = {}, + onClickSparkUsd: () -> Unit = {}, onCalculatorInputActiveChanged: (Boolean) -> Unit = {}, hazeState: HazeState = rememberHazeState(), balances: BalanceState = LocalBalances.current, @@ -588,6 +592,7 @@ private fun Content( onNavigateToSpending = onNavigateToSpending, onClickHardwareWallet = onClickHardwareWallet, onClickUsdt = onClickUsdt, + onClickSparkUsd = onClickSparkUsd, ) 1 -> WidgetsPage( @@ -643,6 +648,7 @@ private fun WalletPage( onNavigateToSpending: () -> Unit, onClickHardwareWallet: (String) -> Unit, onClickUsdt: () -> Unit, + onClickSparkUsd: () -> Unit, ) { val usdt by appViewModel?.usdtWallet?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(UsdtWalletState()) } @@ -650,12 +656,16 @@ private fun WalletPage( val showUsdt = appViewModel?.isUsdtVisible?.collectAsStateWithLifecycle()?.value == true val rate = LocalCurrencies.current.paykitRate val usdtSats = usdtHeadlineSats(usdt.balance, showUsdt, rate) + val sparkUsd = walletViewModel?.sparkState?.collectAsStateWithLifecycle()?.value?.usd + val sparkUsdSats = usdtHeadlineSats(sparkUsd?.balance, sparkUsd != null, rate) val headlineSats = BigInteger( balances.totalWithHardwareSats.toString() - ).add(BigInteger.valueOf(usdtSats ?: 0L)).min(BigInteger.valueOf(Long.MAX_VALUE)).toLong() + ).add(BigInteger.valueOf(usdtSats ?: 0L)).add(BigInteger.valueOf(sparkUsdSats ?: 0L)) + .min(BigInteger.valueOf(Long.MAX_VALUE)).toLong() val heightStatusBar = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val pullToRefreshState = rememberPullToRefreshState() - val hasActivity = !latestActivities.isNullOrEmpty() || usdt.transfers.isNotEmpty() + val hasActivity = !latestActivities.isNullOrEmpty() || usdt.transfers.isNotEmpty() || + sparkUsd?.transfers?.isNotEmpty() == true PullToRefreshBox( state = pullToRefreshState, @@ -685,7 +695,7 @@ private fun WalletPage( BalanceHeaderView( sats = headlineSats, - isUnavailable = usdtSats == null, + isUnavailable = usdtSats == null || sparkUsdSats == null, showEyeIcon = true, testTag = "TotalBalance", modifier = Modifier @@ -700,6 +710,8 @@ private fun WalletPage( onNavigateToSpending = onNavigateToSpending, onClickHardwareWallet = onClickHardwareWallet, onClickUsdt = onClickUsdt, + showSparkUsd = sparkUsd != null, + onClickSparkUsd = onClickSparkUsd, ) VerticalSpacer(32.dp) @@ -733,6 +745,7 @@ private fun WalletPage( items = latestActivities, maxItems = (slotCapacity - countNonItemSlots(homeUiState)).coerceAtLeast(0), usdtItems = usdt.transfers, + sparkUsdItems = sparkUsd?.transfers ?: persistentListOf(), onAllActivityClick = onNavigateToAllActivity, onActivityItemClick = onNavigateToActivityItem, hardwareIds = hardwareIds, @@ -768,6 +781,8 @@ private fun BalancesSection( onNavigateToSpending: () -> Unit, onClickHardwareWallet: (String) -> Unit, onClickUsdt: () -> Unit, + showSparkUsd: Boolean = false, + onClickSparkUsd: () -> Unit = {}, ) { Column(modifier = Modifier.fillMaxWidth()) { Row( @@ -803,6 +818,8 @@ private fun BalancesSection( (appViewModel?.isUsdtVisible?.collectAsStateWithLifecycle()?.value == true), onClickHardwareWallet = onClickHardwareWallet, onClickUsdt = onClickUsdt, + showSparkUsd = showSparkUsd, + onClickSparkUsd = onClickSparkUsd, ) } } @@ -813,8 +830,12 @@ private fun AdditionalWallets( showUsdt: Boolean, onClickHardwareWallet: (String) -> Unit, onClickUsdt: () -> Unit, + showSparkUsd: Boolean = false, + onClickSparkUsd: () -> Unit = {}, ) { - val count = wallets.size + if (showUsdt) 1 else 0 + val usdtIndex = if (showUsdt) wallets.size else -1 + val sparkUsdIndex = if (showSparkUsd) wallets.size + if (showUsdt) 1 else 0 else -1 + val count = wallets.size + (if (showUsdt) 1 else 0) + (if (showSparkUsd) 1 else 0) for (rowStart in 0 until count step 2) { VerticalSpacer(if (wallets.isEmpty()) 24.dp else 16.dp) Row( @@ -828,7 +849,8 @@ private fun AdditionalWallets( val wallet = wallets.getOrNull(index) when { wallet != null -> HwDeviceCell(wallet = wallet, onClick = onClickHardwareWallet) - showUsdt && index == wallets.size -> UsdtWalletBalance(onClick = onClickUsdt) + index == usdtIndex -> UsdtWalletBalance(onClick = onClickUsdt) + index == sparkUsdIndex -> SparkUsdWalletBalance(onClick = onClickSparkUsd) else -> FillWidth() } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt index f9aa4c5da8..317b8d5370 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt @@ -58,7 +58,6 @@ import to.bitkit.ui.utils.withAccent @Composable fun SavingsWalletScreen( - isGeoBlocked: Boolean, onchainActivities: ImmutableList, onAllActivityButtonClick: () -> Unit, onEmptyActivityRowClick: () -> Unit, @@ -74,8 +73,7 @@ fun SavingsWalletScreen( mutableStateOf(!hasFunds && !hasActivity) } val canTransfer by remember(balances.totalOnchainSats) { - val hasFunds = balances.totalOnchainSats > 0uL - mutableStateOf(hasFunds && !isGeoBlocked) + mutableStateOf(balances.totalOnchainSats > 0uL) } val hazeState = rememberHazeState() @@ -198,7 +196,6 @@ private fun Preview() { AppThemeSurface { Box { SavingsWalletScreen( - isGeoBlocked = false, onchainActivities = previewOnchainActivityItems(), onAllActivityButtonClick = {}, onActivityItemClick = {}, @@ -218,7 +215,6 @@ private fun PreviewTransfer() { AppThemeSurface { Box { SavingsWalletScreen( - isGeoBlocked = false, onchainActivities = previewOnchainActivityItems(), onAllActivityButtonClick = {}, onActivityItemClick = {}, @@ -241,7 +237,6 @@ private fun PreviewNoActivity() { AppThemeSurface { Box { SavingsWalletScreen( - isGeoBlocked = false, onchainActivities = persistentListOf(), onAllActivityButtonClick = {}, onActivityItemClick = {}, @@ -255,33 +250,12 @@ private fun PreviewNoActivity() { } } -@Preview(showSystemUi = true) -@Composable -private fun PreviewGeoBlocked() { - AppThemeSurface { - Box { - SavingsWalletScreen( - isGeoBlocked = true, - onchainActivities = previewOnchainActivityItems(), - onAllActivityButtonClick = {}, - onActivityItemClick = {}, - onEmptyActivityRowClick = {}, - onTransferToSpendingClick = {}, - onBackClick = {}, - balances = BalanceState(totalOnchainSats = 50_000u), - ) - TabBar() - } - } -} - @Preview(showSystemUi = true) @Composable private fun PreviewEmpty() { AppThemeSurface { Box { SavingsWalletScreen( - isGeoBlocked = false, onchainActivities = persistentListOf(), onAllActivityButtonClick = {}, onActivityItemClick = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt index 2f2a40b2ad..169ae8b03a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt @@ -69,16 +69,17 @@ fun SpendingWalletScreen( onTransferFromSavingsClick: () -> Unit, onBackClick: () -> Unit, balances: BalanceState = LocalBalances.current, + hasSparkBalance: Boolean = false, ) { val showEmptyState by remember(balances.totalLightningSats, lightningActivities.size) { val hasLnFunds = balances.totalLightningSats == 0uL val hasActivity = lightningActivities.isNotEmpty() mutableStateOf(hasLnFunds && !hasActivity) } - val canTransfer by remember(balances.totalLightningSats, channels.size) { + val canTransfer by remember(balances.totalLightningSats, channels.size, hasSparkBalance) { val hasLnBalance = balances.totalLightningSats > 0uL val hasChannels = channels.isNotEmpty() - mutableStateOf(hasLnBalance && hasChannels) + mutableStateOf(hasLnBalance && (hasChannels || hasSparkBalance)) } val canTransferFromSavings by remember(showEmptyState, balances.totalOnchainSats) { mutableStateOf(showEmptyState && balances.totalOnchainSats > 0uL) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt index 9c06a55ff2..c1addb2229 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -23,6 +24,8 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList import to.bitkit.R +import to.bitkit.repositories.SparkState +import to.bitkit.repositories.SparkUsdTransfer import to.bitkit.ui.appViewModel import to.bitkit.ui.components.Sheet import to.bitkit.ui.scaffold.AppTopBar @@ -35,6 +38,7 @@ import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems import to.bitkit.ui.shared.modifiers.swipeToChangeTab import to.bitkit.ui.shared.util.screen import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.walletViewModel import to.bitkit.viewmodels.ActivityListViewModel internal fun UsdtTransfer.matchesActivityFilter( @@ -57,6 +61,26 @@ internal fun UsdtTransfer.matchesActivityFilter( (end?.let { timestamp <= (it / 1000).toULong() } != false) } +internal fun SparkUsdTransfer.matchesActivityFilter( + tab: ActivityTab, + noTagFilter: Boolean, + search: String, + start: Long?, + end: Long?, +): Boolean { + val tabMatches = when (tab) { + ActivityTab.ALL -> true + ActivityTab.SENT -> kind == SparkUsdTransfer.Kind.SENT + ActivityTab.RECEIVED -> kind == SparkUsdTransfer.Kind.RECEIVED + ActivityTab.OTHER -> kind !in listOf(SparkUsdTransfer.Kind.SENT, SparkUsdTransfer.Kind.RECEIVED) + } + val searchMatches = search.isEmpty() || counterparty?.contains(search, ignoreCase = true) == true || + txHash?.contains(search, ignoreCase = true) == true || search.equals("USDB", ignoreCase = true) + return tabMatches && noTagFilter && searchMatches && + (start?.let { timestamp >= (it / 1000).toULong() } != false) && + (end?.let { timestamp <= (it / 1000).toULong() } != false) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun AllActivityScreen( @@ -80,12 +104,21 @@ fun AllActivityScreen( it.matchesActivityFilter(selectedTab, selectedTags.isEmpty(), searchText, startDate, endDate) }.toImmutableList() } + val sparkState by walletViewModel?.sparkState?.collectAsStateWithLifecycle() ?: remember { + mutableStateOf(SparkState()) + } + val sparkUsdItems = remember(sparkState.usd, selectedTab, searchText, selectedTags, startDate, endDate) { + sparkState.usd?.transfers.orEmpty().filter { + it.matchesActivityFilter(selectedTab, selectedTags.isEmpty(), searchText, startDate, endDate) + }.toImmutableList() + } val tabs = activityTabs val currentTabIndex = tabs.indexOf(selectedTab) AllActivityScreenContent( filteredActivities = filteredActivities, usdtItems = usdtItems, + sparkUsdItems = sparkUsdItems, hardwareIds = hardwareIds, searchText = searchText, onSearchTextChange = { viewModel.setSearchText(it) }, @@ -110,6 +143,7 @@ private fun AllActivityScreenContent( searchText: String, hardwareIds: ImmutableSet = persistentSetOf(), usdtItems: ImmutableList = persistentListOf(), + sparkUsdItems: ImmutableList = persistentListOf(), onSearchTextChange: (String) -> Unit, hasTagFilter: Boolean, selectedTags: ImmutableSet, @@ -162,6 +196,7 @@ private fun AllActivityScreenContent( ActivityListGrouped( items = filteredActivities, usdtItems = usdtItems, + sparkUsdItems = sparkUsdItems, onActivityItemClick = onActivityItemClick, onEmptyActivityRowClick = onEmptyActivityRowClick, hardwareIds = hardwareIds, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt index d1c6dfa722..7d5af21dd3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt @@ -30,6 +30,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import to.bitkit.R import to.bitkit.ext.scopedId +import to.bitkit.repositories.SparkUsdTransfer import to.bitkit.ui.activityListViewModel import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BodyM @@ -37,6 +38,7 @@ import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.TertiaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems +import to.bitkit.ui.screens.wallets.usdt.SparkUsdActivityRow import to.bitkit.ui.screens.wallets.usdt.UsdtActivityRow import to.bitkit.ui.settingsViewModel import to.bitkit.ui.theme.AppThemeSurface @@ -63,6 +65,7 @@ fun ActivityListGrouped( hardwareIds: ImmutableSet = persistentSetOf(), titleProvider: @Composable (Activity) -> String? = { null }, usdtItems: ImmutableList = persistentListOf(), + sparkUsdItems: ImmutableList = persistentListOf(), ) { val app = appViewModel val settings = settingsViewModel @@ -72,8 +75,10 @@ fun ActivityListGrouped( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.fillMaxSize() ) { - if (!items.isNullOrEmpty() || usdtItems.isNotEmpty()) { - val groupedItems = remember(items, usdtItems) { groupActivityItems(items.orEmpty(), usdtItems) } + if (!items.isNullOrEmpty() || usdtItems.isNotEmpty() || sparkUsdItems.isNotEmpty()) { + val groupedItems = remember(items, usdtItems, sparkUsdItems) { + groupActivityItems(items.orEmpty(), usdtItems, sparkUsdItems) + } LazyColumn( state = listState, @@ -93,6 +98,15 @@ fun ActivityListGrouped( if (index < groupedItems.lastIndex) VerticalSpacer(16.dp) } + is SparkUsdTransfer -> { + SparkUsdActivityRow( + item, + hideBalance, + onClick = { app?.navigateToSparkUsdActivity(item.id) } + ) + if (index < groupedItems.lastIndex) VerticalSpacer(16.dp) + } + is String -> { Caption13Up( text = item, @@ -188,6 +202,7 @@ private fun ActivityContactRow( private fun groupedActivityKey(index: Int, item: Any): String = when (item) { is UsdtTransfer -> "usdt_${item.id}" + is SparkUsdTransfer -> "sparkusd_${item.id}" is String -> "header_$item" is Activity.Lightning -> "lightning_${item.scopedId()}" is Activity.Onchain -> "onchain_${item.scopedId()}" @@ -286,7 +301,11 @@ fun LazyListScope.activityListGroupedItems( } // region utils -private fun groupActivityItems(activityItems: List, usdtItems: List = emptyList()): List { +private fun groupActivityItems( + activityItems: List, + usdtItems: List = emptyList(), + sparkUsdItems: List = emptyList(), +): List { val now = Instant.now() val zoneId = ZoneId.systemDefault() val today = now.atZone(zoneId).truncatedTo(ChronoUnit.DAYS) @@ -308,12 +327,13 @@ private fun groupActivityItems(activityItems: List, usdtItems: List entry.item is WalletActivity.Usdt -> entry.item + is WalletActivity.SparkUsd -> entry.item } val timestamp = entry.timestamp.toLong() when { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt index f5737c79f4..1add15c7d3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt @@ -23,11 +23,13 @@ import kotlinx.collections.immutable.persistentSetOf import to.bitkit.R import to.bitkit.ext.scopedId import to.bitkit.ext.timestamp +import to.bitkit.repositories.SparkUsdTransfer import to.bitkit.ui.activityListViewModel import to.bitkit.ui.appViewModel import to.bitkit.ui.components.TertiaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems +import to.bitkit.ui.screens.wallets.usdt.SparkUsdActivityRow import to.bitkit.ui.screens.wallets.usdt.UsdtActivityRow import to.bitkit.ui.settingsViewModel import to.bitkit.ui.theme.AppThemeSurface @@ -39,14 +41,18 @@ fun ActivityListSimple( onActivityItemClick: (Activity) -> Unit, hardwareIds: ImmutableSet = persistentSetOf(), usdtItems: ImmutableList = persistentListOf(), + sparkUsdItems: ImmutableList = persistentListOf(), maxItems: Int = 4, ) { - if (items.isNullOrEmpty() && usdtItems.isEmpty()) return + if (items.isNullOrEmpty() && usdtItems.isEmpty() && sparkUsdItems.isEmpty()) return val app = appViewModel val settings = settingsViewModel val hideBalance by settings?.hideBalance?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(false) } - val rows = remember(items, usdtItems, maxItems) { - (items.orEmpty().map { WalletActivity.Bitcoin(it) } + usdtItems.map { WalletActivity.Usdt(it) }) + val rows = remember(items, usdtItems, sparkUsdItems, maxItems) { + ( + items.orEmpty().map { WalletActivity.Bitcoin(it) } + usdtItems.map { WalletActivity.Usdt(it) } + + sparkUsdItems.map { WalletActivity.SparkUsd(it) } + ) .sortedByDescending { it.timestamp }.take(maxItems) } @@ -73,6 +79,11 @@ fun ActivityListSimple( hideBalance, onClick = { app?.navigateToUsdtActivity(entry.item.id) } ) + is WalletActivity.SparkUsd -> SparkUsdActivityRow( + entry.item, + hideBalance, + onClick = { app?.navigateToSparkUsdActivity(entry.item.id) } + ) } if (index < rows.lastIndex) VerticalSpacer(16.dp) } @@ -114,4 +125,5 @@ internal sealed interface WalletActivity { val timestamp: ULong data class Bitcoin(val item: Activity) : WalletActivity { override val timestamp get() = item.timestamp() } data class Usdt(val item: UsdtTransfer) : WalletActivity { override val timestamp get() = item.timestamp } + data class SparkUsd(val item: SparkUsdTransfer) : WalletActivity { override val timestamp get() = item.timestamp } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt index 654a0dcf43..f1b1a4cbae 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt @@ -41,6 +41,12 @@ class EditInvoiceVM @Inject constructor( isGeoBlocked: Boolean, ) { viewModelScope.launch { + if (walletRepo.canReceiveWithSpark()) { + editInvoiceEffect( + EditInvoiceScreenEffects.ApplyReceiveLiquidityAction(ReceiveAdditionalLiquidityAction.None) + ) + return@launch + } _isLoading.update { true } val inboundCapacitySats = walletRepo.inboundLiquiditySats() val maxCjitAmountSats = maxCjitAmountSats(source, amountSats, inboundCapacitySats, isGeoBlocked) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/LocationBlockScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/LocationBlockScreen.kt index 5b00aa4f5a..f2a15691f5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/LocationBlockScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/LocationBlockScreen.kt @@ -1,29 +1,14 @@ package to.bitkit.ui.screens.wallets.receive -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import to.bitkit.R -import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BottomSheetPreview -import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.sheetHeight -import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.sheets.TransferUnavailableContent import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors @Composable fun LocationBlockScreen( @@ -31,40 +16,13 @@ fun LocationBlockScreen( navigateAdvancedSetup: () -> Unit, modifier: Modifier = Modifier, ) { - Column( - modifier = modifier - .fillMaxSize() - .gradientBackground() - .navigationBarsPadding() - ) { - SheetTopBar(stringResource(R.string.wallet__receive_bitcoin), onBack = onBackPressed) - - Spacer(modifier = Modifier.height(16.dp)) - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .weight(1f), - ) { - BodyM(text = stringResource(R.string.lightning__funding__text_blocked_cjit), color = Colors.White64) - - Spacer(modifier = Modifier.weight(1f)) - - Image( - painter = painterResource(R.drawable.globe), - contentScale = ContentScale.FillWidth, - contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 60.dp) - ) - - Spacer(modifier = Modifier.weight(1f)) - - PrimaryButton(text = stringResource(R.string.onboarding__advanced_setup), onClick = navigateAdvancedSetup) - Spacer(modifier = Modifier.height(16.dp)) - } - } + TransferUnavailableContent( + navTitle = stringResource(R.string.lightning__transfer_unavailable__nav_title), + description = stringResource(R.string.lightning__transfer_unavailable__text), + onContinue = navigateAdvancedSetup, + onBack = onBackPressed, + modifier = modifier, + ) } @Preview(showSystemUi = true) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index f99e7a16c5..1edec88d00 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -123,6 +123,7 @@ fun ReceiveQrScreen( onVerifyHardwareAddress: () -> Unit = {}, showPaymentRequestContacts: Boolean = false, onClickPaymentRequestContacts: () -> Unit = {}, + canReceiveWithSpark: Boolean = false, ) { SetMaxBrightness() @@ -135,8 +136,9 @@ fun ReceiveQrScreen( hasReadyChannels, readyInboundLiquiditySats, walletState.bip21AmountSats, + canReceiveWithSpark, ) { - ReceiveLiquidityDecision.canCreateLightningInvoice( + canReceiveWithSpark || ReceiveLiquidityDecision.canCreateLightningInvoice( hasReadyChannels = hasReadyChannels, inboundCapacitySats = readyInboundLiquiditySats, invoiceAmountSats = walletState.bip21AmountSats, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index b6b56c32fc..e01c880e88 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -92,6 +92,7 @@ fun ReceiveSheet( var editInvoiceSourceTab by remember { mutableStateOf(ReceiveTab.SAVINGS) } var isAdditionalLiquidityAmountEntry by remember { mutableStateOf(false) } val lightningState: LightningState by wallet.lightningState.collectAsStateWithLifecycle() + val sparkState by wallet.sparkState.collectAsStateWithLifecycle() val paymentRequestTargets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() val paymentRequestContacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() var paymentRequestDraft by remember { @@ -157,6 +158,7 @@ fun ReceiveSheet( cjitInvoice = cjitSessionState.cjitInvoice, walletState = walletState, lightningState = lightningState, + canReceiveWithSpark = sparkState.isReady, onClickReceiveCjit = { if (lightningState.isGeoBlocked) { navController.navigateTo(ReceiveRoute.GeoBlock) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdActivityDetail.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdActivityDetail.kt new file mode 100644 index 0000000000..22f2de11c1 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdActivityDetail.kt @@ -0,0 +1,179 @@ +package to.bitkit.ui.screens.wallets.usdt + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import breez_sdk_spark.PaymentStatus +import com.synonym.bitkitcore.usdtFormatAmount +import to.bitkit.R +import to.bitkit.ext.uiDateStyleFor +import to.bitkit.models.formatToModernDisplay +import to.bitkit.repositories.SparkUsdTransfer +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.components.HorizontalSpacer +import to.bitkit.ui.components.UsdtAmountHeader +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.usdtOverviewAmount +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.screens.wallets.activity.components.ActivityRowSurface +import to.bitkit.ui.screens.wallets.activity.components.AmountViewContent +import to.bitkit.ui.screens.wallets.activity.components.CircularIcon +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.uiDateText + +@Composable +fun SparkUsdActivityRow(transfer: SparkUsdTransfer, hideBalance: Boolean, onClick: () -> Unit) { + ActivityRowSurface(onClick = onClick, modifier = Modifier.testTag("SparkUsdActivity-" + transfer.id)) { + SparkUsdActivityIcon(transfer) + HorizontalSpacer(16.dp) + Column(verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.weight(1f)) { + BodyMSB(stringResource(transfer.titleResource), maxLines = 1) + CaptionB( + uiDateText(transfer.timestamp, uiDateStyleFor(transfer.timestamp)), + color = Colors.White64, + maxLines = 1 + ) + } + HorizontalSpacer(16.dp) + AmountViewContent( + title = usdtOverviewAmount(transfer.amount), + titlePrefix = if (transfer.isIncoming) "+" else "−", + subtitle = "$ " + usdtOverviewAmount(transfer.amount), + hideBalance = hideBalance + ) + } +} + +/** Opens one Spark dollar activity by id, from the Spark dollar balance. */ +@Composable +fun SparkUsdActivityScreen(transferId: String, onBack: () -> Unit, viewModel: SparkUsdViewModel = hiltViewModel()) { + val wallet by viewModel.wallet.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() + val transfer = wallet?.transfers?.firstOrNull { it.id == transferId } + if (transfer == null) { + Column(modifier = Modifier.fillMaxSize().background(Colors.Black)) { + AppTopBar(titleText = null, onBackClick = onBack, actions = { DrawerNavIcon() }) + } + return + } + SparkUsdActivityDetail(transfer, settings.hideBalance, onBack) +} + +@Composable +private fun SparkUsdActivityDetail(transfer: SparkUsdTransfer, hideBalance: Boolean, onBack: () -> Unit) { + Column(modifier = Modifier.fillMaxSize().background(Colors.Black)) { + AppTopBar( + titleText = stringResource(transfer.titleResource), + onBackClick = onBack, + actions = { DrawerNavIcon() } + ) + Column(modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 16.dp)) { + VerticalSpacer(16.dp) + Row(verticalAlignment = Alignment.CenterVertically) { + UsdtAmountHeader( + amount = usdtFormatAmount(transfer.amount), + displayAmount = usdtOverviewAmount(transfer.amount), + hideBalance = hideBalance, + prefix = if (transfer.isIncoming) "+" else "−", + modifier = Modifier.weight(1f).testTag("SparkUsdActivityAmount") + ) + SparkUsdActivityIcon(transfer) + } + VerticalSpacer(24.dp) + DetailRow(R.string.wallet__activity_status, stringResource(transfer.statusResource), transfer.color) + DetailRow( + R.string.wallet__activity_date, + uiDateText(transfer.timestamp, uiDateStyleFor(transfer.timestamp)) + ) + transfer.sats?.let { + val label = when { + transfer.isIncoming -> R.string.wallet__activity_sent + else -> R.string.wallet__activity_received + } + DetailRow(label, "₿ " + it.formatToModernDisplay()) + } + DetailRow(R.string.wallet__activity_fee, "$ " + usdtOverviewAmount(transfer.fee)) + transfer.counterparty?.let { DetailRow(R.string.wallet__activity_invoice, it) } + transfer.txHash?.let { DetailRow(R.string.wallet__activity_tx_id, it) } + VerticalSpacer(32.dp) + } + } +} + +@Composable +private fun DetailRow(@StringRes label: Int, value: String, valueColor: Color = Colors.White) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp)) { + Caption13Up(stringResource(label), color = Colors.White64) + VerticalSpacer(8.dp) + BodySSB(value, color = valueColor, maxLines = 3, overflow = TextOverflow.Ellipsis) + } + HorizontalDivider(color = Colors.White10) +} + +@Composable +private fun SparkUsdActivityIcon(transfer: SparkUsdTransfer) { + CircularIcon( + icon = painterResource(transfer.iconResource), + iconColor = transfer.color, + backgroundColor = transfer.color.copy(alpha = 0.16f), + size = 40.dp + ) +} + +private val SparkUsdTransfer.color: Color get() = when (status) { + PaymentStatus.FAILED -> Colors.Red + PaymentStatus.PENDING -> Colors.Yellow + PaymentStatus.COMPLETED -> Colors.Usdt +} + +private val SparkUsdTransfer.iconResource: Int get() = when (status) { + PaymentStatus.FAILED -> R.drawable.ic_x_circle + PaymentStatus.PENDING -> R.drawable.ic_hourglass_simple + PaymentStatus.COMPLETED -> when (kind) { + SparkUsdTransfer.Kind.TO_USD, + SparkUsdTransfer.Kind.TO_BTC, + SparkUsdTransfer.Kind.TO_ARBITRUM, + -> R.drawable.ic_transfer + else -> if (isIncoming) R.drawable.ic_received else R.drawable.ic_sent + } +} + +private val SparkUsdTransfer.statusResource: Int get() = when (status) { + PaymentStatus.FAILED -> R.string.usdt__failed + PaymentStatus.PENDING -> R.string.usdt__pending + PaymentStatus.COMPLETED -> R.string.usdt__confirmed +} + +private val SparkUsdTransfer.titleResource: Int get() = when { + status == PaymentStatus.FAILED -> R.string.usdt__failed + status == PaymentStatus.PENDING -> R.string.usdt__pending + kind == SparkUsdTransfer.Kind.TO_USD -> R.string.usdt__activity_to_usd_spark + kind == SparkUsdTransfer.Kind.TO_BTC -> R.string.usdt__activity_to_btc_spark + kind == SparkUsdTransfer.Kind.TO_ARBITRUM -> R.string.usdt__activity_bridge_spark + isIncoming -> R.string.usdt__received + else -> R.string.usdt__sent +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt new file mode 100644 index 0000000000..a5647dfc81 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdViewModel.kt @@ -0,0 +1,237 @@ +package to.bitkit.ui.screens.wallets.usdt + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import breez_sdk_spark.SdkException +import com.synonym.bitkitcore.usdtFormatAmount +import com.synonym.bitkitcore.usdtParseAmount +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.env.Env +import to.bitkit.ext.runSuspendCatching +import to.bitkit.repositories.SparkRepo +import to.bitkit.repositories.SparkUsdError +import to.bitkit.repositories.SparkUsdQuote +import to.bitkit.repositories.UsdtRepo +import javax.inject.Inject + +@HiltViewModel +class SparkUsdViewModel @Inject constructor( + private val sparkRepo: SparkRepo, + private val settingsStore: SettingsStore, + private val usdtRepo: UsdtRepo, +) : ViewModel() { + val wallet = sparkRepo.state.map { it.usd } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), sparkRepo.state.value.usd) + + val settings = settingsStore.data + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SettingsData(hideBalance = true)) + + /** Bitkit's own Arbitrum USDT address, the bridge's destination; Orchestra only runs on mainnet. */ + val bridgeAddress = combine(usdtRepo.state, sparkRepo.state) { usdt, _ -> usdt.address } + .map { address -> address.takeIf { Env.isSparkBridgeAvailable && it.isNotEmpty() } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + private val _state = MutableStateFlow(SparkUsdSendState()) + val state = _state.asStateFlow() + + fun refresh() = viewModelScope.launch { sparkRepo.refreshState() } + + fun syncNow() = viewModelScope.launch { sparkRepo.syncNow() } + + fun loadReceiveRequest() = viewModelScope.launch { + _state.update { it.copy(receiveRequest = null, error = null) } + sparkRepo.createUsdRequest(amount = null) + .onSuccess { request -> _state.update { it.copy(receiveRequest = request) } } + .onFailure { _state.update { it.copy(error = R.string.usdt__error_network) } } + } + + fun startSend(input: String = "") = _state.update { + SparkUsdSendState(page = SparkUsdPage.RECIPIENT, recipientInput = input) + } + + fun showReceive() { + _state.update { SparkUsdSendState(page = SparkUsdPage.RECEIVE) } + loadReceiveRequest() + } + + fun dismiss() { + if (_state.value.busy) return + _state.update { SparkUsdSendState() } + } + + fun back() = _state.update { + when { + it.busy -> it + it.authenticationRequired -> it.copy(authenticationRequired = false) + it.quote != null && !it.completed -> it.copy(quote = null, error = null) + it.page == SparkUsdPage.REVIEW -> it.copy(page = SparkUsdPage.AMOUNT, error = null) + it.page == SparkUsdPage.AMOUNT -> it.copy(page = SparkUsdPage.RECIPIENT, error = null) + else -> SparkUsdSendState() + } + } + + fun startConvert() = _state.update { SparkUsdSendState(page = SparkUsdPage.CONVERT) } + + fun startBridge() = _state.update { SparkUsdSendState(page = SparkUsdPage.BRIDGE) } + + fun setConvertToUsd(toUsd: Boolean) = _state.update { it.copy(convertToUsd = toUsd, quote = null, error = null) } + + fun quote() = viewModelScope.launch { + val state = _state.value + val amount = runCatching { usdtParseAmount(state.amountInput) }.getOrNull()?.takeIf { it > 0uL } + if (amount == null) { + _state.update { it.copy(error = R.string.usdt__error_amount) } + return@launch + } + val spendsDollars = state.page == SparkUsdPage.BRIDGE || !state.convertToUsd + if (spendsDollars && amount > (wallet.value?.balance ?: 0uL)) { + _state.update { it.copy(error = R.string.usdt__error_balance_spark) } + return@launch + } + val spendsSats = state.page == SparkUsdPage.CONVERT && state.convertToUsd + if (spendsSats && sparkRepo.state.value.balanceSats == 0uL) { + _state.update { it.copy(error = R.string.other__pay_insufficient_spending) } + return@launch + } + _state.update { it.copy(busy = true, error = null) } + val result = when (state.page) { + SparkUsdPage.BRIDGE -> sparkRepo.quoteArbitrumBridge(bridgeAddress.value.orEmpty(), amount) + else -> sparkRepo.quoteConversion(state.convertToUsd, amount) + } + result + .onSuccess { quote -> + val paysSats = quote.kind == SparkUsdQuote.Kind.TO_USD + val affordable = !paysSats || quote.pay <= sparkRepo.state.value.balanceSats + _state.update { + if (affordable) { + it.copy(busy = false, quote = quote) + } else { + it.copy(busy = false, error = R.string.other__pay_insufficient_spending) + } + } + } + .onFailure { error -> _state.update { it.copy(busy = false, error = conversionError(error)) } } + } + + fun confirmQuote() = viewModelScope.launch { + val quote = _state.value.quote ?: return@launch + if (_state.value.busy || _state.value.completed) return@launch + _state.update { it.copy(busy = true, error = null) } + sparkRepo.execute(quote) + .onSuccess { _state.update { it.copy(busy = false, completed = true) } } + .onFailure { error -> _state.update { it.copy(busy = false, error = conversionError(error)) } } + } + + private fun conversionError(error: Throwable): Int = when { + error is SdkException.InsufficientFunds -> R.string.other__pay_insufficient_spending + error.message?.contains("insufficient", ignoreCase = true) == true -> R.string.other__pay_insufficient_spending + else -> R.string.usdt__convert_failed_spark + } + + fun setRecipientInput(input: String) = _state.update { it.copy(recipientInput = input, error = null) } + + fun continueWithRecipient() = viewModelScope.launch { + val input = _state.value.recipientInput.trim() + if (input.isEmpty()) return@launch + _state.update { it.copy(busy = true, error = null) } + sparkRepo.parseUsdRecipient(input) + .onSuccess { recipient -> + _state.update { + it.copy( + busy = false, + recipient = recipient.request, + amount = recipient.amount, + amountInput = recipient.amount?.let(::usdtFormatAmount).orEmpty(), + isAmountFixed = recipient.amount != null, + page = if (recipient.amount != null) SparkUsdPage.REVIEW else SparkUsdPage.AMOUNT, + ) + } + } + .onFailure { error -> + val message = when (error) { + is SparkUsdError -> R.string.usdt__error_address_spark + else -> R.string.usdt__error_network + } + _state.update { it.copy(busy = false, error = message) } + } + } + + fun setAmountInput(input: String) = _state.update { it.copy(amountInput = input, error = null) } + + fun setMaximum() = _state.update { state -> + wallet.value?.balance?.let { state.copy(amountInput = usdtFormatAmount(it), error = null) } ?: state + } + + fun review() = _state.update { state -> + val amount = runCatching { usdtParseAmount(state.amountInput) }.getOrNull()?.takeIf { it > 0uL } + ?: return@update state.copy(error = R.string.usdt__error_amount) + val balance = wallet.value?.balance ?: 0uL + if (amount > balance) return@update state.copy(error = R.string.usdt__error_balance_spark) + state.copy(amount = amount, page = SparkUsdPage.REVIEW, error = null) + } + + fun confirm() = viewModelScope.launch { + val state = _state.value + if (state.busy || state.completed || state.authenticationRequired) return@launch + val amount = state.amount ?: return@launch + if (amount > (wallet.value?.balance ?: 0uL)) { + _state.update { it.copy(error = R.string.usdt__error_balance_spark) } + return@launch + } + val settings = runSuspendCatching { settingsStore.data.first() }.getOrNull() ?: return@launch + if (settings.isPinEnabled && settings.isPinForPaymentsEnabled) { + _state.update { it.copy(authenticationRequired = true) } + } else { + send() + } + } + + fun authenticationVerified() { + if (!_state.value.authenticationRequired) return + _state.update { it.copy(authenticationRequired = false) } + viewModelScope.launch { send() } + } + + private suspend fun send() { + val state = _state.value + val recipient = state.recipient ?: return + val amount = state.amount ?: return + _state.update { it.copy(busy = true, error = null) } + sparkRepo.sendUsd(recipient, amount, state.isAmountFixed) + .onSuccess { id -> _state.update { it.copy(busy = false, completed = true, sentTransferId = id) } } + .onFailure { _state.update { it.copy(busy = false, error = R.string.usdt__send_failed_description) } } + } +} + +enum class SparkUsdPage { WALLET, RECEIVE, RECIPIENT, AMOUNT, REVIEW, CONVERT, BRIDGE } + +@Stable +data class SparkUsdSendState( + val page: SparkUsdPage = SparkUsdPage.WALLET, + val receiveRequest: String? = null, + val recipientInput: String = "", + val recipient: String? = null, + val amountInput: String = "", + val amount: ULong? = null, + val isAmountFixed: Boolean = false, + val convertToUsd: Boolean = true, + val quote: SparkUsdQuote? = null, + val sentTransferId: String? = null, + val busy: Boolean = false, + val completed: Boolean = false, + val authenticationRequired: Boolean = false, + val error: Int? = null, +) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdWalletScreen.kt new file mode 100644 index 0000000000..1dd1ce4f21 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/SparkUsdWalletScreen.kt @@ -0,0 +1,655 @@ +package to.bitkit.ui.screens.wallets.usdt + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import breez_sdk_spark.PaymentStatus +import com.synonym.bitkitcore.usdtFormatAmount +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.delay +import to.bitkit.R +import to.bitkit.models.NewTransactionSheetDetails +import to.bitkit.models.NewTransactionSheetDirection +import to.bitkit.models.NewTransactionSheetType +import to.bitkit.models.formatToModernDisplay +import to.bitkit.repositories.SparkUsdQuote +import to.bitkit.repositories.SparkUsdState +import to.bitkit.repositories.SparkUsdTransfer +import to.bitkit.ui.appViewModel +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.BottomSheet +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.GradientCircularProgressIndicator +import to.bitkit.ui.components.HorizontalSpacer +import to.bitkit.ui.components.NumberPad +import to.bitkit.ui.components.NumberPadActionButton +import to.bitkit.ui.components.NumberPadType +import to.bitkit.ui.components.PaymentAddressInput +import to.bitkit.ui.components.PaymentReviewIllustration +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.SwipeToConfirm +import to.bitkit.ui.components.TabBar +import to.bitkit.ui.components.UsdtAmountHeader +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.WalletBalanceContent +import to.bitkit.ui.components.usdtOverviewAmount +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.screens.wallets.activity.components.activityGroupTitleResource +import to.bitkit.ui.screens.wallets.receive.ReceiveQrView +import to.bitkit.ui.settingsViewModel +import to.bitkit.ui.shared.UiConstants +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.blockPointerInputPassthrough +import to.bitkit.ui.sheets.NewTransactionSheetView +import to.bitkit.ui.theme.AppTextStyles +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.NumberPadInputHandler +import kotlin.time.Duration.Companion.seconds + +/** How often the receive screen syncs with Spark, so an incoming payment shows within seconds. */ +private val RECEIVE_POLL_INTERVAL = 5.seconds + +/** Home tile for the dollar balance on Spark, next to the Arbitrum USDT one. */ +@Composable +fun RowScope.SparkUsdWalletBalance(onClick: () -> Unit, viewModel: SparkUsdViewModel = hiltViewModel()) { + val wallet by viewModel.wallet.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() + WalletBalanceContent( + title = wallet?.ticker ?: "USDT", + titleStyle = UsdtDetailStyles.Caption, + icon = { + Image( + painterResource(R.drawable.tether_circle), + contentDescription = null, + modifier = Modifier.padding(end = 4.dp).size(24.dp) + ) + }, + titleTrailing = { + HorizontalSpacer(4.dp) + CaptionB(stringResource(R.string.lightning__spark), color = Colors.White64) + }, + modifier = Modifier.clickableAlpha(onClick = onClick).padding(vertical = 4.dp).testTag("SparkUsdWallet") + ) { + BodyMSB( + when { + settings.hideBalance -> UiConstants.HIDE_BALANCE_SHORT + else -> wallet?.let { "$ " + usdtOverviewAmount(it.balance) } ?: "—" + }, + style = UsdtDetailStyles.HistoryTitle + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SparkUsdWalletScreen( + onBack: () -> Unit, + initialRecipient: String = "", + viewModel: SparkUsdViewModel = hiltViewModel(), +) { + val wallet by viewModel.wallet.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() + val bridgeAddress by viewModel.bridgeAddress.collectAsStateWithLifecycle() + LaunchedEffect(viewModel, initialRecipient) { + viewModel.refresh() + if (initialRecipient.isNotEmpty()) { + viewModel.startSend(initialRecipient) + viewModel.continueWithRecipient() + } + } + BackHandler(enabled = state.page == SparkUsdPage.WALLET, onBack = onBack) + SparkUsdWalletContent( + wallet = wallet, + hideBalance = settings.hideBalance, + onReceive = viewModel::showReceive, + onSend = { viewModel.startSend() }, + onBack = onBack, + onConvert = viewModel::startConvert, + onBridge = viewModel::startBridge.takeIf { bridgeAddress != null }, + ) + if (state.page == SparkUsdPage.WALLET) return + val blocked by rememberUpdatedState(state.busy) + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { it != SheetValue.Hidden || !blocked }, + ) + BottomSheet( + onDismissRequest = viewModel::dismiss, + sheetState = sheetState, + containerColor = Colors.Gray7, + dragHandleTopPadding = 8.dp, + ) { + BackHandler(onBack = viewModel::back) + Column( + modifier = Modifier.sheetHeight(isModal = true).background(Colors.Gray7) + .navigationBarsPadding().imePadding() + .padding(bottom = 16.dp) + ) { + if (state.authenticationRequired) { + Box(modifier = Modifier.padding(horizontal = 16.dp)) { + UsdtAuthentication( + useBiometrics = settings.isBiometricEnabled, + onCancel = viewModel::back, + onVerified = viewModel::authenticationVerified, + ) + } + } else { + SparkUsdSheetContent( + wallet = wallet, + state = state, + hideBalance = settings.hideBalance, + onBack = viewModel::back, + onRecipientChange = viewModel::setRecipientInput, + onRecipientContinue = viewModel::continueWithRecipient, + onAmountChange = viewModel::setAmountInput, + onMaximum = viewModel::setMaximum, + onReview = viewModel::review, + onConfirm = viewModel::confirm, + onDone = viewModel::dismiss, + onConvertDirection = viewModel::setConvertToUsd, + onQuote = viewModel::quote, + onConfirmQuote = viewModel::confirmQuote, + onPoll = viewModel::syncNow, + ) + } + } + } +} + +@Composable +private fun SparkUsdWalletContent( + wallet: SparkUsdState?, + hideBalance: Boolean, + onReceive: () -> Unit, + onSend: () -> Unit, + onBack: () -> Unit, + onConvert: () -> Unit = {}, + onBridge: (() -> Unit)? = null, +) { + val settings = settingsViewModel + val app = appViewModel + val allowSwipe by settings?.enableSwipeToHideBalance?.collectAsStateWithLifecycle() ?: remember { + mutableStateOf(false) + } + val transfers = wallet?.transfers ?: persistentListOf() + Box(modifier = Modifier.fillMaxSize().background(Colors.Black).blockPointerInputPassthrough()) { + UsdtCoinIllustration( + modifier = Modifier.align(Alignment.TopEnd) + .offset(x = 81.dp, y = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 4.dp) + .scale(scaleX = -1f, scaleY = 1f) + ) + Column { + AppTopBar( + titleText = (wallet?.ticker ?: "USDT") + " · " + stringResource(R.string.lightning__spark), + onBackClick = onBack, + icon = R.drawable.tether_circle, + actions = { DrawerNavIcon() } + ) + LazyColumn(modifier = Modifier.padding(horizontal = 16.dp)) { + item { + VerticalSpacer(16.dp) + UsdtAmountHeader( + amount = wallet?.let { usdtFormatAmount(it.balance) } ?: "—", + displayAmount = wallet?.let { usdtOverviewAmount(it.balance) }, + hideBalance = hideBalance, + onToggleHide = if (allowSwipe) ({ settings?.setHideBalance(!hideBalance) }) else null, + modifier = Modifier.testTag("SparkUsdBalance") + ) + VerticalSpacer(24.dp) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SecondaryButton( + text = stringResource(R.string.usdt__convert_spark), + onClick = onConvert, + size = ButtonSize.Small, + fullWidth = false, + testTag = "SparkUsdConvert", + ) + onBridge?.let { + SecondaryButton( + text = stringResource(R.string.usdt__bridge_spark), + onClick = it, + size = ButtonSize.Small, + fullWidth = false, + testTag = "SparkUsdBridge", + ) + } + } + VerticalSpacer(32.dp) + } + itemsIndexed(transfers, key = { _, transfer -> transfer.id }) { index, transfer -> + val heading = activityGroupTitleResource(transfer.timestamp) + if (index == 0 || activityGroupTitleResource(transfers[index - 1].timestamp) != heading) { + Caption13Up( + stringResource(heading), + color = Colors.White64, + modifier = Modifier.padding(top = if (index == 0) 0.dp else 16.dp, bottom = 16.dp) + ) + } + SparkUsdActivityRow( + transfer, + hideBalance, + onClick = { app?.navigateToSparkUsdActivity(transfer.id) } + ) + VerticalSpacer(16.dp) + } + item { VerticalSpacer(120.dp) } + } + } + TabBar(onSendClick = onSend, onReceiveClick = onReceive, onScanClick = { + app?.showScannerSheet(showBackButton = false) + }) + } +} + +@Composable +private fun SparkUsdSheetContent( + wallet: SparkUsdState?, + state: SparkUsdSendState, + hideBalance: Boolean, + onBack: () -> Unit, + onRecipientChange: (String) -> Unit, + onRecipientContinue: () -> Unit, + onAmountChange: (String) -> Unit, + onMaximum: () -> Unit, + onReview: () -> Unit, + onConfirm: () -> Unit, + onDone: () -> Unit, + onConvertDirection: (Boolean) -> Unit = {}, + onQuote: () -> Unit = {}, + onConfirmQuote: () -> Unit = {}, + onPoll: () -> Unit = {}, +) { + val sentTransferId = state.sentTransferId + if (state.page == SparkUsdPage.REVIEW && state.completed && sentTransferId != null) { + val app = appViewModel + NewTransactionSheetView( + details = NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.SENT, + usdtAmount = state.amount, + sparkUsdTransferId = sentTransferId, + ), + onCloseClick = onDone, + onDetailClick = { + onDone() + app?.navigateToSparkUsdActivity(sentTransferId) + }, + hideBalance = hideBalance, + modifier = Modifier.fillMaxSize().testTag("SparkUsdSendSuccess") + ) + return + } + val title = when { + state.page == SparkUsdPage.RECEIVE -> R.string.usdt__receive_title + state.page == SparkUsdPage.CONVERT -> R.string.usdt__convert_spark + state.page == SparkUsdPage.BRIDGE -> R.string.usdt__bridge_spark + state.completed -> R.string.usdt__sent_title_spark + state.page == SparkUsdPage.REVIEW -> R.string.wallet__send_review + state.page == SparkUsdPage.AMOUNT -> R.string.usdt__amount + else -> R.string.usdt__send_title_spark + } + Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + SheetTopBar(stringResource(title), onBack = onBack.takeUnless { state.busy || state.completed }) + VerticalSpacer(16.dp) + when (state.page) { + SparkUsdPage.RECEIVE -> SparkUsdReceiveContent(state, onPoll) + SparkUsdPage.AMOUNT -> SparkUsdAmountContent( + wallet, + state, + hideBalance, + onAmountChange, + onMaximum, + onReview + ) + SparkUsdPage.REVIEW -> SparkUsdReviewContent(state, onConfirm, onDone) + SparkUsdPage.CONVERT, SparkUsdPage.BRIDGE -> SparkUsdConvertContent( + state, + onAmountChange, + onConvertDirection, + onQuote, + onConfirmQuote, + onDone, + ) + else -> SparkUsdRecipientContent(state, onRecipientChange, onRecipientContinue) + } + } +} + +@Composable +private fun ColumnScope.SparkUsdReceiveContent(state: SparkUsdSendState, onPoll: () -> Unit = {}) { + val request = state.receiveRequest + LaunchedEffect(Unit) { + while (true) { + delay(RECEIVE_POLL_INTERVAL) + onPoll() + } + } + BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) { + when { + request != null -> ReceiveQrView( + // Bech32 is case-insensitive; uppercase fits QR's alphanumeric mode, a less dense code. + uri = request.uppercase(), + copyText = request, + qrLogoPainter = painterResource(R.drawable.tether_circle), + onClickEditInvoice = null, + accentColor = Colors.Usdt, + copyMessage = stringResource(R.string.usdt__receive_copied_spark), + modifier = Modifier.fillMaxWidth().height(minOf(maxHeight, maxWidth + ButtonSize.Small.height + 32.dp)) + .testTag("SparkUsdReceiveQr") + ) + state.error != null -> BodyS(stringResource(state.error), color = Colors.Brand) + else -> GradientCircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + BodyS( + stringResource(R.string.usdt__receive_network, stringResource(R.string.lightning__spark)), + color = Colors.White64, + ) +} + +@Composable +private fun ColumnScope.SparkUsdRecipientContent( + state: SparkUsdSendState, + onRecipientChange: (String) -> Unit, + onRecipientContinue: () -> Unit, +) { + Caption13Up(stringResource(R.string.wallet__send_to), color = Colors.White64) + VerticalSpacer(8.dp) + PaymentAddressInput( + value = state.recipientInput, + onValueChange = onRecipientChange, + placeholder = stringResource(R.string.usdt__address), + modifier = Modifier.fillMaxWidth().weight(1f).testTag("SparkUsdRecipient") + ) + VerticalSpacer(16.dp) + state.error?.let { + BodyS(stringResource(it), color = Colors.Brand, modifier = Modifier.testTag("SparkUsdError")) + VerticalSpacer(16.dp) + } + PrimaryButton( + text = stringResource(R.string.common__continue), + enabled = state.recipientInput.isNotBlank() && !state.busy, + isLoading = state.busy, + onClick = onRecipientContinue, + modifier = Modifier.testTag("SparkUsdRecipientContinue") + ) +} + +@Composable +private fun SparkUsdAmountContent( + wallet: SparkUsdState?, + state: SparkUsdSendState, + hideBalance: Boolean, + onAmountChange: (String) -> Unit, + onMaximum: () -> Unit, + onReview: () -> Unit, +) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val maxHeight = this.maxHeight + Column(modifier = Modifier.fillMaxWidth()) { + UsdtAmountHeader(state.amountInput.ifEmpty { "0" }, modifier = Modifier.testTag("SparkUsdAmount")) + FillHeight(min = 12.dp) + state.error?.let { + BodyS(stringResource(it), color = Colors.Brand, modifier = Modifier.testTag("SparkUsdError")) + VerticalSpacer(16.dp) + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.weight(1f)) { + Caption13Up(stringResource(R.string.wallet__send_available), color = Colors.White64) + VerticalSpacer(4.dp) + BodySSB( + when { + hideBalance -> UiConstants.HIDE_BALANCE_SHORT + else -> wallet?.let { "$ " + usdtOverviewAmount(it.balance) } ?: "—" + } + ) + } + NumberPadActionButton( + text = stringResource(R.string.common__max), + color = Colors.Usdt, + onClick = onMaximum, + textStyle = AppTextStyles.CaptionM.copy(letterSpacing = 1.sp), + modifier = Modifier.testTag("SparkUsdMax") + ) + } + VerticalSpacer(16.dp) + HorizontalDivider(color = Colors.White10) + NumberPad( + type = NumberPadType.DECIMAL, + availableHeight = maxHeight, + onDeleteLongPress = { onAmountChange("") }, + onPress = { key -> + onAmountChange( + NumberPadInputHandler.handleInput(key, state.amountInput, maxLength = 21, maxDecimals = 6) + ) + }, + ) + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = onReview, + enabled = state.amountInput.isNotEmpty(), + modifier = Modifier.testTag("SparkUsdReview") + ) + } + } +} + +@Composable +private fun ColumnScope.SparkUsdReviewContent(state: SparkUsdSendState, onConfirm: () -> Unit, onDone: () -> Unit) { + val amount = state.amount ?: 0uL + val swipeProgress = remember { mutableFloatStateOf(0f) } + UsdtAmountHeader(usdtFormatAmount(amount), displayAmount = usdtOverviewAmount(amount)) + VerticalSpacer(24.dp) + Caption13Up(stringResource(R.string.wallet__send_to), color = Colors.White64) + VerticalSpacer(8.dp) + BodySSB(state.recipient.orEmpty(), maxLines = 2, modifier = Modifier.testTag("SparkUsdReviewRecipient")) + FillHeight() + if (!state.completed) { + PaymentReviewIllustration( + swipeProgress = { swipeProgress.floatValue }, + isUsdt = true, + modifier = Modifier.height(200.dp) + ) + } + state.error?.let { + BodyS(stringResource(it), color = Colors.Brand, modifier = Modifier.testTag("SparkUsdError")) + VerticalSpacer(16.dp) + } + if (state.completed) { + BodyS(stringResource(R.string.usdt__submitted_description), color = Colors.White64) + VerticalSpacer(16.dp) + PrimaryButton( + text = stringResource(R.string.common__close), + onClick = onDone, + modifier = Modifier.testTag("SparkUsdDone") + ) + } else { + SwipeToConfirm( + text = stringResource(R.string.wallet__send_swipe), + color = Colors.Usdt, + onConfirm = onConfirm, + enabled = !state.busy, + loading = state.busy, + confirmed = state.busy || state.authenticationRequired, + progress = swipeProgress, + textStyle = AppTextStyles.BodySSB.copy(letterSpacing = 0.sp), + modifier = Modifier.fillMaxWidth().testTag("SparkUsdConfirm") + ) + } +} + +@Composable +private fun ColumnScope.SparkUsdConvertContent( + state: SparkUsdSendState, + onAmountChange: (String) -> Unit, + onConvertDirection: (Boolean) -> Unit, + onQuote: () -> Unit, + onConfirmQuote: () -> Unit, + onDone: () -> Unit, +) { + val quote = state.quote + if (quote == null) { + if (state.page == SparkUsdPage.CONVERT) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(true to R.string.usdt__convert_to_usd_spark, false to R.string.usdt__convert_to_btc_spark) + .forEach { (toUsd, label) -> + NumberPadActionButton( + text = stringResource(label), + color = if (state.convertToUsd == toUsd) Colors.Usdt else Colors.White64, + onClick = { onConvertDirection(toUsd) }, + textStyle = AppTextStyles.CaptionM.copy(letterSpacing = 1.sp), + modifier = Modifier.testTag(if (toUsd) "SparkUsdConvertToUsd" else "SparkUsdConvertToBtc") + ) + } + } + } else { + BodyS(stringResource(R.string.usdt__bridge_note_spark), color = Colors.White64) + } + VerticalSpacer(16.dp) + UsdtAmountHeader(state.amountInput.ifEmpty { "0" }, modifier = Modifier.testTag("SparkUsdConvertAmount")) + FillHeight(min = 12.dp) + state.error?.let { + BodyS(stringResource(it), color = Colors.Brand, modifier = Modifier.testTag("SparkUsdError")) + VerticalSpacer(16.dp) + } + HorizontalDivider(color = Colors.White10) + NumberPad( + type = NumberPadType.DECIMAL, + availableHeight = 360.dp, + onDeleteLongPress = { onAmountChange("") }, + onPress = { key -> + onAmountChange( + NumberPadInputHandler.handleInput(key, state.amountInput, maxLength = 21, maxDecimals = 6) + ) + }, + ) + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = onQuote, + enabled = state.amountInput.isNotEmpty() && !state.busy, + isLoading = state.busy, + modifier = Modifier.testTag("SparkUsdQuote") + ) + return + } + val bitcoinIn = quote.kind == SparkUsdQuote.Kind.TO_USD + val bitcoinOut = quote.kind == SparkUsdQuote.Kind.TO_BTC + SparkUsdQuoteRow(R.string.usdt__convert_pay_spark, quote.pay.display(bitcoinIn)) + SparkUsdQuoteRow(R.string.usdt__convert_receive_spark, quote.receive.display(bitcoinOut)) + SparkUsdQuoteRow(R.string.usdt__convert_fee_spark, quote.fee.display(isSats = false)) + FillHeight() + state.error?.let { + BodyS(stringResource(it), color = Colors.Brand, modifier = Modifier.testTag("SparkUsdError")) + VerticalSpacer(16.dp) + } + if (state.completed) { + PrimaryButton( + text = stringResource(R.string.common__close), + onClick = onDone, + modifier = Modifier.testTag("SparkUsdDone") + ) + } else { + SwipeToConfirm( + text = stringResource(R.string.other__swipe), + color = Colors.Usdt, + onConfirm = onConfirmQuote, + enabled = !state.busy, + loading = state.busy, + confirmed = state.busy, + textStyle = AppTextStyles.BodySSB.copy(letterSpacing = 0.sp), + modifier = Modifier.fillMaxWidth().testTag("SparkUsdConfirmQuote") + ) + } +} + +@Composable +private fun SparkUsdQuoteRow(label: Int, value: String) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp)) { + Caption13Up(stringResource(label), color = Colors.White64, modifier = Modifier.weight(1f)) + BodySSB(value) + } + HorizontalDivider(color = Colors.White10) +} + +private fun ULong.display(isSats: Boolean): String = + if (isSats) "₿ " + formatToModernDisplay() else "$ " + usdtOverviewAmount(this) + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + SparkUsdWalletContent( + wallet = SparkUsdState( + ticker = "USDB", + balance = 12_340_000uL, + transfers = persistentListOf( + SparkUsdTransfer( + id = "1", + kind = SparkUsdTransfer.Kind.RECEIVED, + status = PaymentStatus.COMPLETED, + amount = 5_000_000uL, + fee = 0uL, + timestamp = 1_793_098_272uL, + ), + ), + ), + hideBalance = false, + onReceive = {}, + onSend = {}, + onBack = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt index db90c67454..67fd099008 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt @@ -142,8 +142,8 @@ private fun Content( NotificationPreview( enabled = hasPermission, - title = stringResource(R.string.notification__received__title), - description = "₿ 21 000 ($21.00)", + title = stringResource(R.string.subscriptions__payment_due_title), + description = stringResource(R.string.subscriptions__payment_due_description), modifier = Modifier.fillMaxWidth() ) } diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index 2e3a75bb1f..b074892039 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -522,14 +522,14 @@ private fun ChannelDetailContent( } @Composable -private fun SectionTitle(text: String) { +internal fun SectionTitle(text: String) { VerticalSpacer(32.dp) Caption13Up(text = text, color = Colors.White64) VerticalSpacer(8.dp) } @Composable -private fun SectionRow( +internal fun SectionRow( name: String, valueContent: @Composable () -> Unit, modifier: Modifier = Modifier, diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt index 84ec1944a8..0e09cbe271 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.settings.lightning +import android.content.Intent import androidx.annotation.DrawableRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Image @@ -38,6 +39,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import kotlinx.collections.immutable.ImmutableList @@ -51,10 +53,12 @@ import to.bitkit.ui.Routes import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.ChannelStatusUi import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.LightningChannel +import to.bitkit.ui.components.MoneyCaptionB import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.SyncNodeView @@ -69,6 +73,7 @@ import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.util.shareZipFile import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.getBlockExplorerUrl import to.bitkit.ui.utils.withAccent private const val CLOSED_CHANNEL_ALPHA = 0.64f @@ -101,6 +106,8 @@ fun LightningConnectionsScreen( onClickChannel = { channelUi -> navController.navigateTo(Routes.ChannelDetail(channelUi.details.channelId)) }, + onClickSpark = { navController.navigateTo(Routes.SparkDetail) }, + onOpenTx = { txId -> context.startActivity(Intent(Intent.ACTION_VIEW, getBlockExplorerUrl(txId).toUri())) }, onRefresh = { viewModel.onPullToRefresh() }, @@ -115,6 +122,8 @@ private fun Content( onClickAddConnection: () -> Unit = {}, onClickExportLogs: () -> Unit = {}, onClickChannel: (ChannelUi) -> Unit = {}, + onClickSpark: () -> Unit = {}, + onOpenTx: (String) -> Unit = {}, onRefresh: () -> Unit = {}, ) { var showClosed by remember { mutableStateOf(false) } @@ -148,7 +157,8 @@ private fun Content( onRefresh = onRefresh, modifier = Modifier.fillMaxSize() ) { - val isEmpty = uiState.openChannels.isEmpty() && + val isEmpty = uiState.spark == null && + uiState.openChannels.isEmpty() && uiState.pendingConnections.isEmpty() && uiState.closedChannels.isEmpty() @@ -159,7 +169,11 @@ private fun Content( .fillMaxSize() ) { VerticalSpacer(16.dp) - LightningBalancesSection(uiState.localBalance, uiState.remoteBalance) + LightningBalancesSection( + uiState.localBalance, + uiState.remoteBalance, + isReceivingUnlimited = uiState.isSparkReceiving + ) HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) EmptyStateContent(modifier = Modifier.weight(1f)) @@ -181,9 +195,19 @@ private fun Content( .verticalScroll(rememberScrollState()) ) { VerticalSpacer(16.dp) - LightningBalancesSection(uiState.localBalance, uiState.remoteBalance) + LightningBalancesSection( + uiState.localBalance, + uiState.remoteBalance, + isReceivingUnlimited = uiState.isSparkReceiving + ) HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) + uiState.spark?.let { spark -> + VerticalSpacer(16.dp) + Caption13Up(stringResource(R.string.lightning__spark), color = Colors.White64) + SparkItem(spark = spark, onClick = onClickSpark, onOpenTx = onOpenTx) + } + // Pending Channels Section if (uiState.pendingConnections.isNotEmpty()) { VerticalSpacer(16.dp) @@ -275,7 +299,7 @@ private fun Content( } @Composable -private fun LightningBalancesSection(localBalance: ULong, remoteBalance: ULong) { +private fun LightningBalancesSection(localBalance: ULong, remoteBalance: ULong, isReceivingUnlimited: Boolean = false) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -291,12 +315,20 @@ private fun LightningBalancesSection(localBalance: ULong, remoteBalance: ULong) balance = remoteBalance, icon = R.drawable.ic_received, color = Colors.White, + // Spark receives without inbound liquidity, so no channel caps receiving. + valueText = stringResource(R.string.lightning__spark_receiving_unlimited).takeIf { isReceivingUnlimited }, ) } } @Composable -private fun BalanceColumn(label: String, balance: ULong, @DrawableRes icon: Int, color: Color) { +private fun BalanceColumn( + label: String, + balance: ULong, + @DrawableRes icon: Int, + color: Color, + valueText: String? = null, +) { Column { Caption13Up(text = label, color = Colors.White64) VerticalSpacer(8.dp) @@ -309,7 +341,7 @@ private fun BalanceColumn(label: String, balance: ULong, @DrawableRes icon: Int, tint = color, modifier = Modifier.size(22.dp) ) - Title(text = balance.toLong().formatToModernDisplay(), color = color) + Title(text = valueText ?: balance.toLong().formatToModernDisplay(), color = color) } } } @@ -380,6 +412,106 @@ private fun ChannelItem( } } +@Composable +private fun SparkItem( + spark: SparkUi, + onClick: () -> Unit, + onOpenTx: (String) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickableAlpha { onClick() } + .testTag("SparkConnection") + ) { + VerticalSpacer(16.dp) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + BodyMSB( + text = stringResource(R.string.lightning__spark_provider_value), + modifier = Modifier.weight(1f) + ) + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = Colors.White64, + modifier = Modifier.size(24.dp) + ) + } + VerticalSpacer(8.dp) + LightningChannel( + capacity = spark.balanceSats.toLong(), + localBalance = spark.balanceSats.toLong(), + remoteBalance = 0L, + status = ChannelStatusUi.OPEN, + showReceiving = false, + ) + spark.pendingDeposits.forEach { SparkDepositRow(it, onOpenTx) } + VerticalSpacer(16.dp) + HorizontalDivider() + } +} + +@Composable +internal fun SparkDepositRow( + depositUi: SparkDepositUi, + onOpenTx: (String) -> Unit, + modifier: Modifier = Modifier, +) { + SparkTransactionRow( + title = stringResource(R.string.lightning__spark_deposit), + txid = depositUi.deposit.txid, + amountSats = depositUi.deposit.amountSats, + confirmations = depositUi.confirmations, + onOpenTx = onOpenTx, + modifier = modifier.testTag("SparkDeposit") + ) +} + +@Composable +internal fun SparkTransactionRow( + title: String, + txid: String, + amountSats: ULong, + onOpenTx: (String) -> Unit, + modifier: Modifier = Modifier, + confirmations: UInt? = null, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .padding(top = 12.dp) + .clickableAlpha { onOpenTx(txid) } + ) { + Column(modifier = Modifier.weight(1f)) { + CaptionB(text = title) + CaptionB( + text = txid, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + } + Column( + horizontalAlignment = Alignment.End, + modifier = Modifier.padding(start = 16.dp) + ) { + MoneyCaptionB(sats = amountSats.toLong(), symbol = true) + confirmations?.let { + CaptionB( + text = stringResource(R.string.lightning__spark_confirmations, minOf(it.toInt(), 3)), + color = Colors.Purple, + ) + } + } + } +} + @Composable private fun EmptyStateContent(modifier: Modifier = Modifier) { Column(modifier = modifier) { diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt index 74bc1d85be..6da1b919c1 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt @@ -23,9 +23,11 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.OutPoint import to.bitkit.R import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env import to.bitkit.ext.amountOnClose import to.bitkit.ext.calculateRemoteBalance import to.bitkit.ext.createChannelDetails @@ -37,6 +39,10 @@ import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LogsRepo +import to.bitkit.repositories.SparkDeposit +import to.bitkit.repositories.SparkRepo +import to.bitkit.repositories.SparkState +import to.bitkit.repositories.SparkWithdrawal import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject @@ -50,6 +56,7 @@ class LightningConnectionsViewModel @Inject constructor( private val blocktankRepo: BlocktankRepo, private val logsRepo: LogsRepo, private val activityRepo: ActivityRepo, + private val sparkRepo: SparkRepo, ) : ViewModel() { companion object { @@ -96,9 +103,10 @@ class LightningConnectionsViewModel @Inject constructor( combine( lightningRepo.lightningState, blocktankRepo.blocktankState, - ) { lightningState, blocktankState -> - Pair(lightningState, blocktankState) - }.collect { (lightningState, blocktankState) -> + sparkRepo.state, + ) { lightningState, blocktankState, sparkState -> + Triple(lightningState, blocktankState, sparkState) + }.collect { (lightningState, blocktankState, sparkState) -> val channels = lightningState.channels val connectionText = context.getString(R.string.lightning__connection) @@ -114,8 +122,10 @@ class LightningConnectionsViewModel @Inject constructor( failedOrders = getFailedOrdersAsChannels(blocktankState.paidOrders) .map { it.mapToUiModel(channels, blocktankState.paidOrders, connectionText) } .reversed().toImmutableList(), - localBalance = calculateLocalBalance(channels), + localBalance = calculateLocalBalance(channels).safe() + sparkState.balanceSats.safe(), remoteBalance = channels.calculateRemoteBalance(), + spark = sparkState.toUi(lightningRepo), + isSparkReceiving = sparkState.isReady, ) } } @@ -149,6 +159,7 @@ class LightningConnectionsViewModel @Inject constructor( } suspend fun refreshObservedState() { + sparkRepo.refreshState() lightningRepo.sync() blocktankRepo.refreshOrders() loadClosedChannels() @@ -300,8 +311,39 @@ data class LightningConnectionsUiState( val closedChannels: ImmutableList = persistentListOf(), val localBalance: ULong = 0u, val remoteBalance: ULong = 0u, + val spark: SparkUi? = null, + val isSparkReceiving: Boolean = false, ) +/** The Spark wallet shown as a connection, present once it holds a balance or a deposit. */ +data class SparkUi( + val balanceSats: ULong, + val deposits: ImmutableList = persistentListOf(), + val withdrawals: ImmutableList = persistentListOf(), +) { + val pendingDeposits get() = deposits.filterNot { it.deposit.isClaimed } + val claimedDeposits get() = deposits.filter { it.deposit.isClaimed } +} + +data class SparkDepositUi( + val deposit: SparkDeposit, + /** On-chain confirmations, known for deposits this wallet sent. */ + val confirmations: UInt? = null, +) + +internal suspend fun SparkState.toUi(lightningRepo: LightningRepo): SparkUi? { + if (!isReady) return null + // On regtest Spark always shows, so testers reach its deposit address to fund it from the faucet. + if (balanceSats == 0uL && deposits.isEmpty() && Env.network != Network.REGTEST) return null + return SparkUi( + balanceSats = balanceSats, + deposits = deposits.map { + SparkDepositUi(it, if (it.isClaimed) null else lightningRepo.onchainConfirmations(it.txid)) + }.toImmutableList(), + withdrawals = withdrawals, + ) +} + data class ChannelUi( val name: String, val details: ChannelDetails, diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt new file mode 100644 index 0000000000..c3f9dc13bc --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt @@ -0,0 +1,205 @@ +package to.bitkit.ui.settings.lightning + +import android.content.Intent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.collections.immutable.persistentListOf +import to.bitkit.R +import to.bitkit.ext.setClipboardText +import to.bitkit.repositories.SparkDeposit +import to.bitkit.repositories.SparkWithdrawal +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.components.ChannelStatusUi +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.LightningChannel +import to.bitkit.ui.components.MoneyCaptionB +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.getBlockExplorerUrl + +@Composable +fun SparkDetailScreen( + onBack: () -> Unit, + onTransferToSavings: () -> Unit, + viewModel: SparkDetailViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + Content( + uiState = uiState, + onBack = onBack, + onRefresh = viewModel::onPullToRefresh, + onOpenTx = { txId -> context.startActivity(Intent(Intent.ACTION_VIEW, getBlockExplorerUrl(txId).toUri())) }, + onCopyText = { context.setClipboardText(it) }, + onTransferToSavings = onTransferToSavings, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun Content( + uiState: SparkDetailUiState, + onBack: () -> Unit = {}, + onRefresh: () -> Unit = {}, + onOpenTx: (String) -> Unit = {}, + onCopyText: (String) -> Unit = {}, + onTransferToSavings: () -> Unit = {}, +) { + val spark = uiState.spark ?: SparkUi(balanceSats = 0uL) + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.lightning__spark), + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = onRefresh, + modifier = Modifier.fillMaxSize() + ) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .testTag("SparkScrollView") + ) { + VerticalSpacer(16.dp) + LightningChannel( + capacity = spark.balanceSats.toLong(), + localBalance = spark.balanceSats.toLong(), + remoteBalance = 0L, + status = ChannelStatusUi.OPEN, + showReceiving = false, + ) + VerticalSpacer(32.dp) + HorizontalDivider() + + SectionTitle(stringResource(R.string.lightning__spark_provider)) + SectionRow( + name = stringResource(R.string.lightning__spark_provider), + valueContent = { CaptionB(text = stringResource(R.string.lightning__spark_provider_value)) }, + ) + VerticalSpacer(12.dp) + BodyS(text = stringResource(R.string.lightning__spark_trust), color = Colors.White64) + + uiState.depositAddress?.let { address -> + SectionRow( + name = stringResource(R.string.wallet__activity_address), + valueContent = { + CaptionB( + text = address, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + textAlign = TextAlign.End, + ) + }, + onClick = { onCopyText(address) }, + modifier = Modifier.testTag("SparkDepositAddress"), + ) + } + + SectionTitle(stringResource(R.string.lightning__balance)) + SectionRow( + name = stringResource(R.string.lightning__spending_label), + valueContent = { MoneyCaptionB(sats = spark.balanceSats.toLong(), symbol = true) }, + ) + + if (spark.pendingDeposits.isNotEmpty()) { + SectionTitle(stringResource(R.string.lightning__spark_deposits_pending)) + spark.pendingDeposits.forEach { SparkDepositRow(it, onOpenTx) } + } + + if (spark.claimedDeposits.isNotEmpty()) { + SectionTitle(stringResource(R.string.lightning__spark_deposits_claimed)) + spark.claimedDeposits.forEach { depositUi -> + SparkDepositRow(depositUi, onOpenTx) + SectionRow( + name = stringResource(R.string.lightning__spark_claim_fee), + valueContent = { + MoneyCaptionB(sats = depositUi.deposit.feeSats.toLong(), symbol = true) + }, + ) + } + } + + if (spark.withdrawals.isNotEmpty()) { + SectionTitle(stringResource(R.string.lightning__savings_confirm__label)) + spark.withdrawals.forEach { withdrawal -> + SparkTransactionRow( + title = stringResource(R.string.wallet__activity_transfer_to_savings), + txid = withdrawal.txid, + amountSats = withdrawal.amountSats, + onOpenTx = onOpenTx, + modifier = Modifier.testTag("SparkWithdrawal") + ) + SectionRow( + name = stringResource(R.string.wallet__activity_fee), + valueContent = { MoneyCaptionB(sats = withdrawal.feeSats.toLong(), symbol = true) }, + ) + } + } + + FillHeight() + VerticalSpacer(32.dp) + PrimaryButton( + text = stringResource(R.string.wallet__transfer_to_savings), + onClick = onTransferToSavings, + enabled = spark.balanceSats > 0uL, + modifier = Modifier + .fillMaxWidth() + .testTag("SparkTransferToSavings") + ) + VerticalSpacer(16.dp) + } + } + } +} + +@Preview +@Composable +private fun Preview() { + AppThemeSurface { + Content( + uiState = SparkDetailUiState( + spark = SparkUi( + balanceSats = 35_000uL, + deposits = persistentListOf( + SparkDepositUi(SparkDeposit("6c8706b8a9cbd18f", 15_000uL, isClaimed = false), 1u), + SparkDepositUi(SparkDeposit("d0067b2497c7b905", 35_000uL, isClaimed = true, feeSats = 312uL)), + ), + withdrawals = persistentListOf(SparkWithdrawal("4f1d0c55e2a9b713", 4_917uL, feeSats = 1_470uL)), + ), + ), + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailViewModel.kt new file mode 100644 index 0000000000..ac14080045 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailViewModel.kt @@ -0,0 +1,64 @@ +package to.bitkit.ui.settings.lightning + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.lightningdevkit.ldknode.Network +import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.SparkRepo +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +@HiltViewModel +class SparkDetailViewModel @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val lightningRepo: LightningRepo, + private val sparkRepo: SparkRepo, +) : ViewModel() { + private val _uiState = MutableStateFlow(SparkDetailUiState()) + val uiState = _uiState.asStateFlow() + + init { + viewModelScope.launch(bgDispatcher) { + sparkRepo.state.collect { state -> _uiState.update { it.copy(spark = state.toUi(lightningRepo)) } } + } + if (Env.network == Network.REGTEST) { + viewModelScope.launch(bgDispatcher) { + sparkRepo.depositAddress().onSuccess { address -> + _uiState.update { + it.copy( + depositAddress = address + ) + } + } + } + } + } + + fun onPullToRefresh() { + viewModelScope.launch { + _uiState.update { it.copy(isRefreshing = true) } + sparkRepo.refreshState() + _uiState.update { it.copy(spark = sparkRepo.state.value.toUi(lightningRepo)) } + delay(500.milliseconds) + _uiState.update { it.copy(isRefreshing = false) } + } + } +} + +@Immutable +data class SparkDetailUiState( + val spark: SparkUi? = null, + val isRefreshing: Boolean = false, + /** Spark's on-chain deposit address, shown on regtest to fund Spark from Lightspark's regtest faucet. */ + val depositAddress: String? = null, +) diff --git a/app/src/main/java/to/bitkit/ui/sheets/NewTransactionSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/NewTransactionSheet.kt index 65c708d047..61cb33fe7a 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/NewTransactionSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/NewTransactionSheet.kt @@ -155,7 +155,7 @@ fun NewTransactionSheetView( hideBalance = hideBalance, onClick = { val hasDetails = details.activityId != null || details.paymentHashOrTxId != null || - details.usdtTransferId != null + (details.usdtTransferId != null || details.sparkUsdTransferId != null) if (!details.isLoadingDetails && hasDetails) onDetailClick() }, modifier = Modifier.testTag("ReceivedTransaction") @@ -179,6 +179,11 @@ fun NewTransactionSheetView( private fun TransactionHeader(details: NewTransactionSheetDetails) { val titleText = stringResource( when { + details.sparkUsdTransferId != null -> if (details.direction == NewTransactionSheetDirection.SENT) { + R.string.usdt__sent_title_spark + } else { + R.string.usdt__received_title_spark + } details.usdtAmount != null -> if (details.direction == NewTransactionSheetDirection.SENT) { R.string.usdt__payment_sent } else { @@ -252,7 +257,8 @@ private fun TransactionButtons( } else { null } - if (details.direction == NewTransactionSheetDirection.SENT || details.usdtTransferId != null) { + val hasDollarDetails = details.usdtTransferId != null || details.sparkUsdTransferId != null + if (details.direction == NewTransactionSheetDirection.SENT || hasDollarDetails) { Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier @@ -264,7 +270,7 @@ private fun TransactionButtons( onClick = onDetailClick, textStyle = buttonStyle, enabled = !details.isLoadingDetails && ( - details.activityId != null || details.paymentHashOrTxId != null || details.usdtTransferId != null + details.activityId != null || details.paymentHashOrTxId != null || hasDollarDetails ), modifier = Modifier .weight(1f) diff --git a/app/src/main/java/to/bitkit/ui/sheets/TransferUnavailableSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/TransferUnavailableSheet.kt new file mode 100644 index 0000000000..761f478988 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/TransferUnavailableSheet.kt @@ -0,0 +1,70 @@ +package to.bitkit.ui.sheets + +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.core.net.toUri +import to.bitkit.R +import to.bitkit.env.Env +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.SheetIntro +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent + +/** Shown instead of the Blocktank transfer to spending, which is no longer available. */ +@Composable +fun TransferUnavailableSheet( + onContinue: () -> Unit, + modifier: Modifier = Modifier, +) { + TransferUnavailableContent( + navTitle = stringResource(R.string.lightning__transfer_unavailable__nav_title), + description = stringResource(R.string.lightning__transfer_unavailable__text), + onContinue = onContinue, + modifier = modifier.sheetHeight(isModal = true), + ) +} + +@Composable +fun TransferUnavailableContent( + navTitle: String, + description: String, + onContinue: () -> Unit, + modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, +) { + val context = LocalContext.current + SheetIntro( + navTitle = navTitle, + title = stringResource(R.string.lightning__transfer_unavailable__title).withAccent(accentColor = Colors.Purple), + description = AnnotatedString(description), + image = R.drawable.lightning, + continueText = stringResource(R.string.common__continue), + onContinue = onContinue, + cancelText = stringResource(R.string.common__learn_more), + onCancel = { + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Env.BLOCKTANK_SUNSET_URL.toUri())) } + }, + onBack = onBack, + testTag = "TransferUnavailable", + cancelTestTag = "TransferUnavailableLearnMore", + continueTestTag = "TransferUnavailableContinue", + modifier = modifier, + ) +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + TransferUnavailableSheet(onContinue = {}) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt b/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt index fe43ccd1f5..53080db8ac 100644 --- a/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt +++ b/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt @@ -13,10 +13,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.ui.areNotificationsEnabled +import to.bitkit.viewmodels.SettingsViewModel @Composable fun RequestNotificationPermissions( @@ -120,3 +123,22 @@ fun rememberNotificationToggleClick( } } } + +/** Asks for notification permission when a Paykit subscription starts, unless it is already granted. */ +@Composable +fun rememberSubscriptionNotificationsRequest( + settingsViewModel: SettingsViewModel = hiltViewModel(), +): () -> Unit { + val isGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() + val requestPermission = rememberRequestNotificationPermission( + onPermissionResult = settingsViewModel::setNotificationPreference, + onPreTiramisu = {}, + ) + val currentIsGranted by rememberUpdatedState(isGranted) + + return remember(requestPermission) { + { + if (!currentIsGranted) requestPermission() + } + } +} diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index 6ae7003a66..e7014d0476 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -20,6 +20,7 @@ import to.bitkit.models.safe import to.bitkit.models.toBalance import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.SparkRepo import to.bitkit.repositories.TransferRepo import to.bitkit.utils.Logger import to.bitkit.utils.jsonLogOf @@ -33,6 +34,7 @@ class DeriveBalanceStateUseCase @Inject constructor( private val transferRepo: TransferRepo, private val settingsStore: SettingsStore, private val hwWalletRepo: HwWalletRepo, + private val sparkRepo: SparkRepo, ) { suspend operator fun invoke(): Result = withContext(bgDispatcher) { runCatching { @@ -56,16 +58,19 @@ class DeriveBalanceStateUseCase @Inject constructor( val channelFundableBalance = getMaxChannelFundableAmount(lightningRepo.getChannelFundableBalance()) val afterPendingChannels = balanceDetails.totalLightningBalanceSats.safe() - pendingChannelsSats.safe() val afterClosingChannels = afterPendingChannels.safe() - toSavingsAmount.safe() - val totalLightningSats = afterClosingChannels.safe() - lingeringCoopCloseSats.safe() + val sparkSats = sparkRepo.state.value.balanceSats + val afterLingeringCoopClose = afterClosingChannels.safe() - lingeringCoopCloseSats.safe() + val totalLightningSats = afterLingeringCoopClose.safe() + sparkSats.safe() val balanceState = BalanceState( totalOnchainSats = totalOnchainSats, channelFundableBalance = channelFundableBalance, totalLightningSats = totalLightningSats, - maxSendLightningSats = lightningRepo.getChannels().totalNextOutboundHtlcLimitSats(), + // A payment goes over one rail, so the larger of the two balances caps it. + maxSendLightningSats = maxOf(lightningRepo.getChannels().totalNextOutboundHtlcLimitSats(), sparkSats), maxSendOnchainSats = getMaxSendAmount(balanceDetails), balanceInTransferToSavings = toSavingsAmount.safe() - coopCloseSavingsSats.safe(), - balanceInTransferToSpending = toSpendingAmount, + balanceInTransferToSpending = toSpendingAmount.safe() + sparkRepo.state.value.pendingDepositSats.safe(), hardwareWallets = hwWalletRepo.wallets.value.map { it.toBalance() }, ) diff --git a/app/src/main/java/to/bitkit/utils/LoggerLdk.kt b/app/src/main/java/to/bitkit/utils/LoggerLdk.kt index ecba3cfb79..7c8fc8d52a 100644 --- a/app/src/main/java/to/bitkit/utils/LoggerLdk.kt +++ b/app/src/main/java/to/bitkit/utils/LoggerLdk.kt @@ -6,7 +6,6 @@ import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.LogRecord import org.lightningdevkit.ldknode.LogWriter import org.lightningdevkit.ldknode.Node -import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import java.io.File @@ -24,7 +23,7 @@ class LoggerLdk @Inject constructor( private const val TAG = "LogDumperLdk" } - fun dumpNetworkGraphInfo(node: Node, trustedPeers: List, bolt11: String) { + fun dumpNetworkGraphInfo(node: Node, bolt11: String) { val nodeIdPreviewLen = 20 val sb = StringBuilder() @@ -137,19 +136,6 @@ class LoggerLdk @Inject constructor( } } - sb.appendLine("\n Checking for trusted peers in network graph:") - var foundTrustedNodes = 0 - trustedPeers.forEach { peer -> - val nodeId = peer.nodeId - if (allNodes.any { it == nodeId }) { - foundTrustedNodes++ - sb.appendLine(" OK: ${nodeId.take(nodeIdPreviewLen)}... found in graph") - } else { - sb.appendLine(" MISSING: ${nodeId.take(nodeIdPreviewLen)}... NOT in graph") - } - } - sb.appendLine(" Summary: $foundTrustedNodes/${trustedPeers.size} trusted peers found in graph") - val nodesToShow = minOf(10, allNodes.size) sb.appendLine("\n First $nodesToShow nodes:") allNodes.take(nodesToShow).forEachIndexed { index, nodeId -> diff --git a/app/src/main/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheet.kt b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheet.kt index 068dcf5561..dbba61a0e6 100644 --- a/app/src/main/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheet.kt +++ b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheet.kt @@ -3,7 +3,7 @@ package to.bitkit.utils.timedsheets.sheets import kotlinx.coroutines.flow.first import to.bitkit.data.SettingsStore import to.bitkit.ext.nowMillis -import to.bitkit.repositories.WalletRepo +import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.ui.components.TimedSheetType import to.bitkit.utils.Logger import to.bitkit.utils.timedsheets.ONE_WEEK_ASK_INTERVAL_MILLIS @@ -14,7 +14,7 @@ import kotlin.time.ExperimentalTime class NotificationsTimedSheet @Inject constructor( private val settingsStore: SettingsStore, - private val walletRepo: WalletRepo, + private val paykitPaymentRequestRepo: PaykitPaymentRequestRepo, ) : TimedSheetItem { override val type = TimedSheetType.NOTIFICATIONS override val priority = 3 @@ -22,7 +22,7 @@ class NotificationsTimedSheet @Inject constructor( override suspend fun shouldShow(): Boolean { val settings = settingsStore.data.first() if (settings.notificationsGranted) return false - if (walletRepo.balanceState.value.totalLightningSats == 0UL) return false + if (paykitPaymentRequestRepo.subscriptions.value.isEmpty()) return false return checkTimeout( lastIgnoredMillis = settings.notificationsIgnoredMillis, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c6cefa81e4..ab95f78b1a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -215,6 +215,7 @@ import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.QuickPayPaymentFailedError import to.bitkit.repositories.QuickPayRepo import to.bitkit.repositories.SamRockRepo +import to.bitkit.repositories.SparkRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.UsdtPreferencesRepo import to.bitkit.repositories.UsdtRepo @@ -311,6 +312,7 @@ class AppViewModel @Inject constructor( private val paykitPaymentRequestDiagnostics: PaykitPaymentRequestDiagnostics, private val refreshContactPaykitLink: RefreshContactPaykitLinkUseCase, private val usdtRepo: UsdtRepo, + private val sparkRepo: SparkRepo, val paykitUsdtPayments: PaykitUsdtPaymentRepo, private val samRockRepo: SamRockRepo, private val appUpdateSheet: AppUpdateTimedSheet, @@ -624,6 +626,26 @@ class AppViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.Eagerly, false) init { + viewModelScope.launch { + sparkRepo.usdReceived.collect { + showTransactionSheet( + NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + usdtAmount = it.amount, + sparkUsdTransferId = it.id, + ) + ) + } + } + viewModelScope.launch { + sparkRepo.state.map { it.usd != null }.distinctUntilChanged().collect { + if (it && isPaykitEnabled.value) { + refreshPublicPaykitEndpointsIfEnabled() + refreshPrivatePaykitEndpointsIfEnabled("Spark dollar availability", requeueActive = true) + } + } + } viewModelScope.launch { usdtPreferences.enabled.distinctUntilChanged().collect { updateCanSwitchWallet() @@ -1854,6 +1876,15 @@ class AppViewModel @Inject constructor( request, IncomingPaykitPaymentRequestFailureReason.EndpointNotPayable, ) + } else if ( + result != PublicPaykitPaymentResult.WaitingForUpdatedPaymentList && + requestedPaymentRequestId.value == request.id + ) { + finishFailedRequestedPaymentRequestPresentation( + request = request, + reason = result?.incomingPaymentRequestFailureReason + ?: IncomingPaykitPaymentRequestFailureReason.ResolutionFailed, + ) } else { deferPaymentRequestPresentation( request = request, @@ -1875,6 +1906,24 @@ class AppViewModel @Inject constructor( return true } + private fun finishFailedRequestedPaymentRequestPresentation( + request: PaykitPaymentRequest, + reason: IncomingPaykitPaymentRequestFailureReason, + ) { + paykitPaymentRequestDiagnostics.logPresentationRejection(request.counterparty, reason) + val restorePaymentRequestSheet = shouldRestorePaymentRequestSheet + paymentRequestPresentationGeneration++ + clearRequestedPaymentRequest() + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__payment_request), + description = context.getString(R.string.slashtags__error_pay_waiting_msg), + testTag = "PaymentRequestWaitingForDetailsToast", + ) + if (restorePaymentRequestSheet) showSheet(Sheet.PaymentRequests) + completePaymentRequestPresentation(request) + } + private fun finishPrivateLinkPendingPaymentRequestPresentation(request: PaykitPaymentRequest) { paykitPaymentRequestDiagnostics.logPresentationRejection( request.counterparty, @@ -3050,6 +3099,8 @@ class AppViewModel @Inject constructor( return@withContext } + if (Env.sparkUsdTokenId != null && SparkRepo.isSparkInput(input)) return@withContext + val scanResult = runCatching { coreService.decode(input.removeLightningSchemes()) } if (scanResult.isFailure) { @@ -3735,7 +3786,16 @@ class AppViewModel @Inject constructor( } private suspend fun openPaykitEndpoints(payment: ContactPaymentContext, fromMainScanner: Boolean) { - val decoded = payment.endpoints.filter { it.methodId != MethodId.UsdtArbitrum } + val sparkUsdEndpoint = payment.endpoints.firstOrNull { it.methodId == MethodId.UsdbSpark } + val holdsSparkUsd = (sparkRepo.state.value.usd?.balance ?: 0uL) > 0uL + if (sparkUsdEndpoint != null && holdsSparkUsd && payment.incomingPaymentRequest == null) { + clearActiveContactPaymentContext() + hideSheet() + mainScreenEffect(MainScreenEffect.Navigate(Routes.SparkUsdWallet(sparkUsdEndpoint.value))) + return + } + val decoded = payment.endpoints + .filter { it.methodId != MethodId.UsdtArbitrum && it.methodId != MethodId.UsdbSpark } .map { coreService.decode(it.paymentRequest) } val address = decoded.filterIsInstance().lastOrNull()?.invoice?.address.orEmpty() val invoice = decoded.filterIsInstance().lastOrNull()?.invoice @@ -4435,6 +4495,12 @@ class AppViewModel @Inject constructor( return@withContext } + if (contactPaymentContext == null && Env.sparkUsdTokenId != null && SparkRepo.isSparkInput(input)) { + hideSheet() + mainScreenEffect(MainScreenEffect.Navigate(Routes.SparkUsdWallet(input.trim()))) + return@withContext + } + val contactPaymentProfile = activeContactPaymentProfile() val incomingPaymentRequest = activeIncomingPaymentRequest() val isPaymentRequest = incomingPaymentRequest != null @@ -4577,7 +4643,7 @@ class AppViewModel @Inject constructor( private fun requiresPaykitAssetSelection(payment: ContactPaymentContext): Boolean = payment.endpoints.isNotEmpty() && ( - payment.endpoints.any { it.methodId == MethodId.UsdtArbitrum } || + payment.endpoints.any { it.methodId == MethodId.UsdtArbitrum || it.methodId == MethodId.UsdbSpark } || payment.incomingPaymentRequest?.let { it.amount.asset != PaykitAsset.BTC || it.pricing.conversion != null } == true @@ -6048,6 +6114,14 @@ class AppViewModel @Inject constructor( feeRateSatsPerVByte: ULong, approvePrepared: suspend (OnchainPreparedReceipt, List) -> Unit = { _, _ -> }, ): Result { + // Blocktank no longer sells channels, so an unpaid order is never paid again. + if (original.orderId != null && lightningRepo.isTransferBlocked()) { + Logger.warn("Stopped retrying a transfer to spending: transfers are unavailable", context = TAG) + showSheet(Sheet.TransferUnavailable) + return Result.failure( + IllegalStateException(context.getString(R.string.lightning__transfer_unavailable__nav_title)) + ) + } val request = original.requestId?.let { id -> (paykitPaymentRequestRepo.pendingRequests.value + paykitPaymentRequestRepo.paymentRequestHistory.value) .firstOrNull { it.id == id } @@ -6543,6 +6617,11 @@ class AppViewModel @Inject constructor( fun onClickActivityDetail() { val details = _transactionSheet.value + details.sparkUsdTransferId?.let { + hideNewTransactionSheet() + navigateToSparkUsdActivity(it) + return + } details.usdtTransferId?.let { hideNewTransactionSheet() navigateToUsdtActivity(it) @@ -6647,18 +6726,22 @@ class AppViewModel @Inject constructor( paymentDeadlineAt = paymentDeadlineAt, onBeforeSend = onBeforeSend, ).onSuccess { hash -> - // Wait until matching payment event is received (with timeout for hold invoices) - val result = lightningRepo.nodeEvents.watchUntil(LightningRepo.SEND_LN_TIMEOUT) { - when (it) { - is Event.PaymentSuccessful if it.paymentHash == hash -> WatchResult.Complete(Result.success(hash)) - is Event.PaymentFailed if it.paymentHash == hash -> WatchResult.Complete( - Result.failure( - LightningPaymentFailedError(reason = it.reason, paymentRequest = bolt11) - ) - ) + fun outcome(event: Event): Result? = when (event) { + is Event.PaymentSuccessful if event.paymentHash == hash -> Result.success(hash) + is Event.PaymentFailed if event.paymentHash == hash -> Result.failure( + LightningPaymentFailedError(reason = event.reason, paymentRequest = bolt11) + ) - else -> WatchResult.Continue() + else -> null + } + // Wait until matching payment event is received (with timeout for hold invoices) + val result = coroutineScope { + val watch = async(start = CoroutineStart.UNDISPATCHED) { + lightningRepo.nodeEvents.watchUntil(LightningRepo.SEND_LN_TIMEOUT) { + outcome(it)?.let { result -> WatchResult.Complete(result) } ?: WatchResult.Continue() + } } + lightningRepo.settledSparkSendEvent(hash)?.let(::outcome)?.also { watch.cancel() } ?: watch.await() } return result ?: Result.failure(PaymentPendingException(hash)) } @@ -6682,6 +6765,13 @@ class AppViewModel @Inject constructor( } } + fun navigateToSparkUsdActivity(transferId: String) { + viewModelScope.launch { + hideSheet() + mainScreenEffect(MainScreenEffect.Navigate(Routes.SparkUsdActivity(transferId))) + } + } + fun navigateToUsdtActivity(transferId: String) { viewModelScope.launch { hideSheet() @@ -7289,6 +7379,7 @@ class AppViewModel @Inject constructor( when { isUsdtPaymentInput(text.trim()) -> isUsdtEnabled.value && !hasActiveUsdtSend() && parseUsdtPaymentRequest(text.trim()).isSuccess + Env.sparkUsdTokenId != null && SparkRepo.isSparkInput(text) -> true else -> SamRockSetupRequest.isProtocolUrl(text) || runSuspendCatching { coreService.decode(text) }.isSuccess } } @@ -8216,6 +8307,7 @@ class AppViewModel @Inject constructor( viewModelScope.launch { if (isOnline.value == ConnectivityState.CONNECTED) pubkyRepo.restoreSessionIfNeeded() } + viewModelScope.launch { sparkRepo.syncNow() } } fun onHomeResumed() { diff --git a/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt index 350c2c716b..04605d199f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt @@ -18,6 +18,7 @@ import to.bitkit.models.BackupItemStatus import to.bitkit.models.NodeLifecycleState import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.SparkRepo import javax.inject.Inject @HiltViewModel @@ -25,6 +26,7 @@ class BackupsViewModel @Inject constructor( private val cacheStore: CacheStore, private val backupRepo: BackupRepo, private val lightningRepo: LightningRepo, + private val sparkRepo: SparkRepo, ) : ViewModel() { private val _uiState = MutableStateFlow(BackupStatusUiState()) @@ -37,7 +39,10 @@ class BackupsViewModel @Inject constructor( private fun collectState() { viewModelScope.launch { cacheStore.backupStatuses.collect { cachedStatuses -> - val categories = BackupCategory.entries.map { category -> + // Spark's exit data shows only where Spark runs. + val categories = BackupCategory.entries.filter { + it != BackupCategory.SPARK_EXIT || sparkRepo.state.value.isSupported + }.map { category -> val cachedStatus = cachedStatuses[category] ?: BackupItemStatus(synced = 0, required = 1) category.toUiState(cachedStatus) } diff --git a/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt index 65d6c1e48f..b0ef143132 100644 --- a/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt @@ -4,22 +4,18 @@ import android.content.Context import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.google.firebase.messaging.FirebaseMessaging import com.synonym.bitkitcore.LegacyRnCloseRecoveryScanResult import com.synonym.bitkitcore.LegacyRnCloseRecoverySweepPreview -import com.synonym.bitkitcore.testNotification import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.tasks.await import to.bitkit.R import to.bitkit.data.AppDb import to.bitkit.data.CacheStore import to.bitkit.data.WidgetsStore -import to.bitkit.env.Env import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType @@ -48,7 +44,6 @@ data class LegacyRnRecoveryUiState( @HiltViewModel class DevSettingsViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val firebaseMessaging: FirebaseMessaging, private val lightningRepo: LightningRepo, private val walletRepo: WalletRepo, private val widgetsStore: WidgetsStore, @@ -166,30 +161,6 @@ class DevSettingsViewModel @Inject constructor( .onFailure { ToastEventBus.send(it) } } - fun registerForNotifications() = viewModelScope.launch { - lightningRepo.registerForNotifications() - .onSuccess { - @Suppress("KotlinConstantConditions") - val title = if (Env.isE2eTest) "Skipped push registration" else "Registered for notifications" - ToastEventBus.send(type = Toast.ToastType.INFO, title = title) - } - .onFailure { ToastEventBus.send(it) } - } - - fun testLspNotification() = viewModelScope.launch { - runCatching { - testNotification( - deviceToken = firebaseMessaging.token.await(), - secretMessage = "hello", - notificationType = "incomingHtlc", - customUrl = Env.blocktankNotificationApiUrl, - ) - ToastEventBus.send(type = Toast.ToastType.INFO, title = "LSP notification sent to this device") - }.onFailure { - ToastEventBus.send(type = Toast.ToastType.WARNING, title = "Error testing LSP notification") - } - } - fun fakeBgReceive() = viewModelScope.launch { cacheStore.setBackgroundReceive( NewTransactionSheetDetails( diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 61db2e66a3..1ba5879a02 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -75,6 +75,7 @@ import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.OnchainTransferContext +import to.bitkit.repositories.SparkRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.BoltzService @@ -107,6 +108,7 @@ class TransferViewModel @Inject constructor( private val boltzService: BoltzService, private val currencyRepo: CurrencyRepo, private val clock: Clock, + private val sparkRepo: SparkRepo, ) : ViewModel() { /** Paired hardware wallets, for screens that render vendor-specific device visuals. */ @@ -145,6 +147,7 @@ class TransferViewModel @Inject constructor( fun onConfirmAmount(satsAmount: Long) { if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) return + if (isSparkFunding()) return onConfirmSparkAmount(satsAmount.toULong()) val values = blocktankRepo.calculateLiquidityOptions(satsAmount.toULong()).getOrNull() if (values == null || values.maxLspBalanceSat == 0uL) { setTransferEffect( @@ -197,6 +200,29 @@ class TransferViewModel @Inject constructor( } } + /** Blocktank no longer sells channels, so Spending is funded by a deposit into the Spark wallet. */ + private fun isSparkFunding(): Boolean = sparkRepo.state.value.isReady + + private fun onConfirmSparkAmount(amountSats: ULong) { + viewModelScope.launch { + if (!canFundOrder(amountSats)) { + setTransferEffect( + TransferEffect.ToastError( + title = context.getString(R.string.lightning__spending_amount__error_balance__title), + description = context.getString( + R.string.lightning__spending_amount__error_balance__description + ), + ) + ) + return@launch + } + onEstimateReady(clientBalanceSat = amountSats, lspBalanceSat = 0uL, feeSat = amountSats, isSpark = true) + sparkRepo.estimateDepositClaimFeeSats(amountSats) + .onSuccess { fee -> _spendingUiState.update { it.copy(sparkClaimFeeSats = fee) } } + .onFailure { Logger.warn("Failed to estimate Spark claim fee", it, context = TAG) } + } + } + private suspend fun estimateSpendingFee( clientBalanceSat: ULong, lspBalanceSat: ULong, @@ -295,9 +321,14 @@ class TransferViewModel @Inject constructor( confirmFeeJob = viewModelScope.launch { _spendingUiState.update { it.copy(isConfirmFeeReady = false) } val order = _spendingUiState.value.order + val address = if (_spendingUiState.value.isSpark) { + sparkRepo.depositAddress().onFailure { ToastEventBus.send(it) }.getOrNull() + } else { + order?.fundingAddress ?: spendingSizingAddress() + } val target = SpendingFundingTarget( feeSat = _spendingUiState.value.feeSat, - address = order?.fundingAddress ?: spendingSizingAddress() ?: return@launch, + address = address ?: return@launch, orderId = order?.id, ) buildSpendingConfirmFundingPlan(target) @@ -337,6 +368,7 @@ class TransferViewModel @Inject constructor( confirmPayJob = viewModelScope.launch { try { val paid = runSuspendCatching { + if (state.isSpark) return@runSuspendCatching paySparkDeposit(state) val order = getOrCreateSpendingOrder() ?: return@runSuspendCatching false paySpendingConfirmOrder(order, shown = state) }.onFailure { @@ -355,9 +387,40 @@ class TransferViewModel @Inject constructor( } } + private suspend fun paySparkDeposit(shown: TransferToSpendingUiState): Boolean { + val plan = spendingConfirmFundingPlan?.takeIf { it.miningFeeSats > 0uL } ?: run { + ToastEventBus.send(type = Toast.ToastType.ERROR, title = context.getString(R.string.common__try_again)) + return false + } + val address = sparkRepo.depositAddress().getOrThrow() + val outcome = lightningRepo.sendOnChain( + address = address, + sats = shown.clientBalanceSat, + speed = TransactionSpeed.Fast, + utxosToSpend = if (plan.shouldUseSendAll) null else plan.selectedUtxos, + feeRates = plan.feeRates, + isTransfer = true, + isMaxAmount = plan.shouldUseSendAll, + ).getOrThrow() + if (outcome !is OnchainSendOutcome.Accepted) { + showRetainedFundingPending() + return false + } + withContext(NonCancellable) { + runSuspendCatching { lightningRepo.completeAcceptedTransferFollowup(orderId = null, txid = outcome.txid) } + .onFailure { Logger.warn("Failed to finish accepted Spark funding locally", it, context = TAG) } + } + sparkRepo.onDepositBroadcast(outcome.txid, shown.clientBalanceSat) + settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_2) } + Logger.info("Funded Spark wallet with '${shown.clientBalanceSat}' sats in '${outcome.txid}'", context = TAG) + return true + } + private suspend fun getOrCreateSpendingOrder(): IBtOrder? { val state = _spendingUiState.value - return state.order ?: blocktankRepo.createOrder( + state.order?.let { return it } + if (isTransferUnavailable()) return null + return blocktankRepo.createOrder( spendingBalanceSats = state.clientBalanceSat, receivingBalanceSats = state.lspBalanceSat, ).getOrElse { @@ -414,6 +477,7 @@ class TransferViewModel @Inject constructor( return false } if (cacheStore.data.first().paidOrders.containsKey(order.id)) return true + if (isTransferUnavailable()) return false val plan = resolveSpendingConfirmPlan(order, shown) ?: return false Logger.debug( @@ -477,6 +541,13 @@ class TransferViewModel @Inject constructor( ) } + private suspend fun isTransferUnavailable(): Boolean { + if (!lightningRepo.isTransferBlocked()) return false + Logger.warn("Skipped paying spending order: transfers are unavailable", context = TAG) + transferEffects.emit(TransferEffect.OnTransferUnavailable) + return true + } + private suspend fun showRetainedFundingPending(): Boolean { val attempt = lightningRepo.currentOnchainSendAttempt() ?: return false if (!attempt.isTransfer || !attempt.blocksNextSend) return false @@ -816,7 +887,12 @@ class TransferViewModel @Inject constructor( } } - private suspend fun onEstimateReady(clientBalanceSat: ULong, lspBalanceSat: ULong, feeSat: ULong) { + private suspend fun onEstimateReady( + clientBalanceSat: ULong, + lspBalanceSat: ULong, + feeSat: ULong, + isSpark: Boolean = false, + ) { settingsStore.update { it.copy(lightningSetupStep = 0) } if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) return pendingHwFundingBroadcast = null @@ -829,6 +905,7 @@ class TransferViewModel @Inject constructor( feeSat = feeSat, order = null, isAdvanced = false, + isSpark = isSpark, isConfirmPaying = false, hasPendingHwBroadcast = false, hwMiningFeeSats = 0uL, @@ -847,6 +924,19 @@ class TransferViewModel @Inject constructor( _spendingUiState.update { it.copy(fundingBudgetSats = fundingBudget, hwFundingWalletId = null) } val availableAmount = fundingBudget ?: 0uL + if (isSparkFunding()) { + val maxSend = availableAmount.toLong() + _spendingUiState.update { + it.copy( + maxAllowedToSend = maxSend, + isLoading = false, + balanceAfterFee = maxSend, + quarterAmount = (maxSend.toDouble() * 0.25).roundToLong(), + ) + } + return@launch + } + val initialLspFees = estimateInitialLspFees(availableAmount) if (initialLspFees == null) { _spendingUiState.update { it.copy(isLoading = false) } @@ -1227,6 +1317,7 @@ class TransferViewModel @Inject constructor( val signedOrder = _spendingUiState.value.order val isBroadcastRetry = signedOrder != null && pendingHwFundingBroadcast?.matches(signedOrder, walletId, signedOrder.fundingAddress) == true + if (isTransferUnavailable()) return@launch if (!isBroadcastRetry && hwWalletRepo.needsPassphrase(walletId)) { _spendingUiState.update { it.copy(isHwPassphraseRequired = true) } return@launch @@ -1859,14 +1950,27 @@ class TransferViewModel @Inject constructor( private fun resolveSavingsTransferMode(): SavingsTransferMode = if (_savingsSwapState.value.quote != null) SavingsTransferMode.SWAP else SavingsTransferMode.CLOSE + /** Whether Transfer to Savings closes channels, rather than only withdrawing Spark's balance. */ + fun hasChannelsToClose(): Boolean = channelsToClose.isNotEmpty() + /** Closes the channels selected earlier, pending closure */ - suspend fun closeSelectedChannels() = closeChannels(channelsToClose) + suspend fun closeSelectedChannels(): List { + withdrawSparkToSavings() + return closeChannels(channelsToClose) + } - fun separateTrustedChannels( - channels: List, - ): Pair, List> = lightningRepo.separateTrustedChannels(channels) + private suspend fun withdrawSparkToSavings() { + val balance = sparkRepo.state.value.balanceSats + if (balance == 0uL) return + val address = lightningRepo.newAddress().getOrElse { + Logger.error("Failed to get address for Spark withdrawal", it, context = TAG) + return + } + sparkRepo.withdrawAll(address).onFailure { ToastEventBus.send(it) } + } private suspend fun closeChannels(channels: List): List { + if (channels.isEmpty()) return emptyList() lightningRepo.awaitPeerConnected() val channelsFailedToClose = coroutineScope { channels.map { channel -> @@ -1897,7 +2001,6 @@ class TransferViewModel @Inject constructor( fun startCoopCloseRetries( channels: List, onGiveUp: () -> Unit, - onTransferUnavailable: () -> Unit, ) { val startTimeMs = clock.now().toEpochMilliseconds() channelsToClose = channels @@ -1921,59 +2024,20 @@ class TransferViewModel @Inject constructor( delay(RETRY_INTERVAL_MS) } - Logger.info("Giving up on coop close. Checking if force close is possible.", context = TAG) - - // Check if any channels can be force closed (filter out trusted peers) - val (_, nonTrustedChannels) = lightningRepo.separateTrustedChannels(channelsToClose) - - if (nonTrustedChannels.isNotEmpty()) { - onGiveUp() - } else { - Logger.warn("All channels are with trusted peers. Cannot force close.", context = TAG) - channelsToClose = emptyList() - onTransferUnavailable() - } + Logger.info("Giving up on coop close.", context = TAG) + onGiveUp() } } fun forceTransfer(onComplete: () -> Unit) = viewModelScope.launch { _isForceTransferLoading.value = true runCatching { - // Filter out trusted peer channels (cannot force close LSP channels) - val (trustedChannels, nonTrustedChannels) = lightningRepo.separateTrustedChannels(channelsToClose) - - if (trustedChannels.isNotEmpty()) { - Logger.warn("Skipping ${trustedChannels.size} trusted peer channel(s)", context = TAG) - } - - if (nonTrustedChannels.isEmpty()) { - channelsToClose = emptyList() - Logger.error("Cannot force close channels with trusted peer", context = TAG) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.lightning__force_failed_title), - description = context.getString(R.string.lightning__force_failed_msg) - ) - return@runCatching - } - - val failedChannels = forceCloseChannels(nonTrustedChannels) - - // Remove successfully closed channels and trusted peer channels from the list - val successfulChannelIds = nonTrustedChannels - .filterNot { channel -> failedChannels.any { it.channelId == channel.channelId } } - .map { it.channelId } - .toSet() - val trustedChannelIds = trustedChannels.map { it.channelId }.toSet() - channelsToClose = channelsToClose.filterNot { - it.channelId in successfulChannelIds || it.channelId in trustedChannelIds - } + val failedChannels = forceCloseChannels(channelsToClose) + channelsToClose = failedChannels if (failedChannels.isEmpty()) { Logger.info("Force close initiated successfully for all channels", context = TAG) - val initMsg = context.getString(R.string.lightning__force_init_msg) - val skippedMsg = context.getString(R.string.lightning__force_channels_skipped) - val description = if (trustedChannels.isNotEmpty()) "$initMsg $skippedMsg" else initMsg + val description = context.getString(R.string.lightning__force_init_msg) ToastEventBus.send( type = Toast.ToastType.LIGHTNING, title = context.getString(R.string.lightning__force_init_title), @@ -2094,6 +2158,10 @@ data class TransferToSpendingUiState( val feeSat: ULong = 0uL, val order: IBtOrder? = null, val isAdvanced: Boolean = false, + /** Funds the Spark wallet instead of buying a Blocktank channel. */ + val isSpark: Boolean = false, + /** Estimated fee Spark keeps from the deposit to claim it into Spending. */ + val sparkClaimFeeSats: ULong = 0uL, val maxAllowedToSend: Long = 0, val balanceAfterFee: Long = 0, val quarterAmount: Long = 0, @@ -2170,6 +2238,7 @@ sealed interface TransferEffect { data object OnSpendingFundingPaid : TransferEffect data class OnFundingPending(val attempt: to.bitkit.repositories.OnchainSendAttempt) : TransferEffect data object OnHwTxSigned : TransferEffect + data object OnTransferUnavailable : TransferEffect data class ToastException(val e: Throwable) : TransferEffect data class ToastError(val title: String, val description: String) : TransferEffect } diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 05ed1b131e..8f35c9b0be 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -42,6 +42,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.NodeStartYieldedToStopError import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.RecoveryModeError +import to.bitkit.repositories.SparkRepo import to.bitkit.repositories.SyncSource import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WipeInProgressError @@ -70,6 +71,7 @@ class WalletViewModel @Inject constructor( private val migrationService: MigrationService, private val connectivityRepo: ConnectivityRepo, private val boltzService: BoltzService, + sparkRepo: SparkRepo, ) : ViewModel() { companion object { private const val TAG = "WalletViewModel" @@ -89,6 +91,7 @@ class WalletViewModel @Inject constructor( } val lightningState = lightningRepo.lightningState + val sparkState = sparkRepo.state val walletState = walletRepo.walletState val balanceState = walletRepo.balanceState diff --git a/app/src/main/res/drawable/ic_device_tablet_speaker.xml b/app/src/main/res/drawable/ic_device_tablet_speaker.xml new file mode 100644 index 0000000000..4ac0ee6adb --- /dev/null +++ b/app/src/main/res/drawable/ic_device_tablet_speaker.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 0894e7c3af..8eb033ed42 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -9,8 +9,8 @@ دعوة مدفوعات فورية إنفاق - عندما يكون Bitkit مغلقًا - استلم أموالك + تذكيرات الدفع + الإشعارات إعداد رمز PIN تأمين امسح وادفع @@ -150,7 +150,6 @@ وقت تحديث ذاكرة التخزين المؤقت لمعدل الرسوم الرسوم تحويل إجباري - تعذر إغلاق بعض الاتصالات. تعذر تحويل أموالك إلى المدخرات. يرجى المحاولة مرة أخرى. فشل التحويل الإجباري ستكون أموالك متاحة خلال ±14 يومًا. @@ -162,8 +161,6 @@ استخدم محفظة أخرى رصيد الإنفاق أسهل خيار هو استخدام مدخرات Bitkit الخاصة بك أو إرسال بيتكوين من محفظة مختلفة. - لا يوفر Bitkit حاليًا خدمات Lightning في بلدك، لكن يمكنك الاتصال بعقد أخرى مباشرة. - لا يوفر Bitkit حاليًا خدمات Lightning في بلدك، لكن يمكنك الاتصال بعقد أخرى مباشرة. موّل <accent>رصيد الإنفاق</accent> رمز QR لقناة LNURL إعداد يدوي @@ -228,6 +225,16 @@ في قائمة انتظار الفتح جارٍ فتح الاتصال يرجى الانتظار، جارٍ تحويل أموالك. يجب أن يستغرق هذا <accent>±10 دقائق.</accent> + Spark + رسوم المطالبة + %1$d/3 تأكيدات + إيداع + إيداعات تمت المطالبة بها + إيداعات قيد التنفيذ + المزوّد + Spark عبر Breez + بلا حد + يُحفظ هذا الرصيد على Spark الذي تديره Lightspark وBreez وFlashnet. يبقى آمنًا ما دام أحدهم على الأقل يتصرف بأمانة. يمكنهم إيقاف الوصول مؤقتًا، والخروج من دونهم يتطلب خروجًا على السلسلة. قابل للإنفاق على السلسلة الإنفاق رسوم السيولة @@ -273,22 +280,11 @@ يرجى الانتظار أثناء ترحيل بيانات محفظتك القديمة إلى إصدار Bitkit الجديد. عادةً ما يستغرق هذا أقل من دقيقة. ترحيل المحفظة جارٍ ترحيل\n<accent>المحفظة</accent> - تم نقل الرصيد من الإنفاق إلى المدخرات - السبب: %s - تم إغلاق القناة قناة الإشعارات لخدمة عقدة Lightning في المقدمة إشعار عقدة Lightning - فشل فتح القناة في الخلفية - فشل فتح القناة - تم فتح القناة - معلق - جاهز للإرسال - خطأ Lightning - فشل الدفع - يرجى المحاولة مرة أخرى تم استلام %s تم استلام الدفع - Bitkit يعمل في الخلفية حتى تتمكن من استلام مدفوعات Lightning + Bitkit يعمل في الخلفية حتى تتمكن من استلام المدفوعات والطلبات إيقاف التطبيق متقدم إعداد متقدم @@ -578,6 +574,7 @@ جهات الاتصال الملف الشخصي الإعدادات + بيانات الخروج من Spark العلامات سجل المعاملات التعزيزات والتحويلات @@ -591,16 +588,16 @@ نسخ محفظتك احتياطيًا نسخ البيانات الاحتياطية تخصيص في إعدادات Bitkit على Android - المدفوعات في الخلفية معطلة، لأنك رفضت الإشعارات. - المدفوعات في الخلفية مفعلة. يمكنك استلام الأموال حتى عندما يكون التطبيق مغلقًا (إذا كان جهازك متصلاً بالإنترنت). - فعّل الإشعارات لاستلام الأموال، حتى عندما يكون تطبيق Bitkit مغلقًا. - استلم الأموال\n<accent>بشكل سلبي</accent> + الإشعارات متوقفة لأنك رفضتها. + الإشعارات مفعّلة. يمكن لـ Bitkit تذكيرك بالاشتراكات وطلبات الدفع. + فعّل الإشعارات لتصلك تذكيرات باشتراكاتك وطلبات الدفع. + ابقَ\n<accent>على اطلاع</accent> الإشعارات إيقاف تشغيل الإعداد في الخلفية - استلم الأموال عندما يكون Bitkit مغلقًا - المدفوعات في الخلفية + السماح بالإشعارات + الإشعارات يجد تطابقات دقيقة للمبلغ لتقليل الصرف Branch and Bound اختيار عشوائي للخصوصية @@ -745,15 +742,27 @@ المدفوعات معلق الحالة + تم النقل إلى USDT + تم التحويل إلى بيتكوين + تم التحويل إلى دولارات عنوان المستلم المبلغ الجسر بحاجة إلى متابعة + ينقل الدولارات من Spark إلى محفظة USDT الخاصة بك على Arbitrum. يحدد مزود الجسر الرسوم. الجسر مرجع الجسر أُعيدت USDT إلى محفظتك. اطّلع على التفاصيل لمعرفة المبلغ المسترد. + إلى USDT دفعتك معلقة. يمكنك تتبّع التأكيد والتسليم عبر الشبكات في النشاط. اسمح بالوصول إلى الكاميرا لمسح رموز QR لمدفوعات USDT. مؤكد + لم يكتمل التحويل. حاول مرة أخرى بعد قليل. + الرسوم + تدفع + تستلم (تقديري) + تحويل + احصل على بيتكوين + احصل على دولارات نسخ التفاصيل جارٍ التسليم جارٍ تسليم USDT @@ -794,9 +803,11 @@ فعّل USDT لمتابعة هذا الدفع أو الطلب. تفعيل USDT؟ أدخل عنوانًا صالحًا للشبكة المحددة. + أدخل عنوان Spark أو فاتورة Spark لهذا الرمز. أدخل مبلغًا صالحًا لا يتجاوز 6 منازل عشرية. تعذر نسخ هذا الدفع احتياطيًا. ستُعاد المحاولة عند توفر النسخ الاحتياطي. رصيد USDT غير كافٍ لتغطية المبلغ والرسوم. + لا توجد دولارات كافية على Spark لهذا المبلغ. المبلغ صغير جدًا لتغطية تكاليف الجسر. جرّب مبلغًا أكبر. اضبط تاريخ جهازك ووقته تلقائيًا، ثم حاول مرة أخرى. USDT غير متاح حاليًا. يرجى المحاولة مرة أخرى لاحقًا. @@ -830,6 +841,7 @@ معلق المبلغ تم نسخ العنوان إلى الحافظة.\nتأكد من إرسال USDT على الشبكة المحددة فقط. + تم نسخ الفاتورة إلى الحافظة. الاستلام (تقديري) الرسوم الرسوم المقدّرة @@ -838,6 +850,7 @@ شبكة USDT استلام الدولارات مستلم + تم استلام الدولارات شبكة الاستلام جارٍ استلام USDT يحصل المستلم على @@ -847,9 +860,11 @@ لم يكتمل دفعك. تحقق من النشاط لمعرفة التفاصيل. اختر الشبكة التي يستخدمها المستلم. إرسال USDT + إرسال دولارات شبكة المرسل مُرسل تم إرسال USDT + تم إرسال الدولارات USDT (Tether) تم تقديم الدفع تتم معالجة دفعك. يمكنك تتبّع حالته في النشاط. @@ -1264,6 +1279,7 @@ لم يتم العثور على القناة تم تحويل الأموال في رصيد إنفاقك إلى مدخراتك. تم إغلاق الاتصال + تحويل من محفظة الأجهزة إلى المدخرات المبلغ منخفض جدًا للتحويل إلى المدخرات. إغلاق القناة بدلاً من ذلك @@ -1288,6 +1304,9 @@ وقّع باستخدام\n<accent>جهازك</accent> تم توقيع\n<accent>المعاملة</accent> التحويل جاهز خلال %s + التحويل غير متاح + لم تعد التحويلات التلقائية إلى رصيد الإنفاق متاحة. استخدم عقدتك الخاصة، أو أنشئ اتصالًا مع مزوّد آخر. + <accent>الإعداد اليدوي</accent>\nمطلوب يرجى التأكد من أن لديك اتصالاً مستقرًا بالإنترنت. قد تظهر البيانات بشكل غير صحيح أثناء محاولة الاتصال. تم اكتشاف مشاكل في الشبكة لا يحتوي رمز QR هذا على SeedQR صالح من 12 كلمة. @@ -1551,7 +1570,7 @@ اسم الاشتراك مستمر نظرة عامة - افتح Bitkit لمراجعة دفعة اشتراك. + افتح Bitkit لمراجعة الدفعة دفعة اشتراك مستحقة طرق الدفع يوميًا diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index b051b9e1e7..2ec19c5056 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -9,8 +9,8 @@ Invitar Pagos instantáneos Gastar - Sin Bitkit abierto - Cobrar + Recordatorios de pago + Notificaciones Configurar un código PIN PROTEGER Escanear y pagar @@ -157,7 +157,6 @@ Hora de actualización de caché de tarifa Tarifa Fuerzar transferencia - Algunas conexiones no pudieron cerrarse. No se pudo transferir tus fondos a ahorros. Intenta de nuevo. Transferencia forzada falló Sus fondos estarán accesibles en ±14 días. @@ -169,8 +168,6 @@ Usar Otra Billetera Saldo de gastos La forma más fácil es usar tus ahorros de Bitkit o enviar bitcoin desde una billetera diferente. - Bitkit no tiene soporte Lightning en tu región, pero puedes usar nodos externos manualmente. - Bitkit no tiene soporte Lightning en tu región, pero puedes usar nodos externos manualmente. Fondee su <accent>saldo de gastos</accent> QR de canal LNURL Configuración Manual @@ -235,6 +232,16 @@ En Fila Para la Apertura Abriendo Conexión Por favor, espere, su transferencia está en curso. Esto debería tardar <accent>±10 minutos.</accent> + Spark + Comisión de reclamo + %1$d/3 confirmaciones + Depósito + Depósitos reclamados + Depósitos en curso + Proveedor + Spark mediante Breez + Sin límite + Este saldo se guarda en Spark, operado por Lightspark, Breez y Flashnet. Está seguro mientras al menos uno de ellos actúe con honestidad. Pueden pausar el acceso, y salir sin ellos requiere una salida on-chain. Gastable onchain Gasto Comisión por liquidez @@ -281,22 +288,11 @@ Por favor espera mientras se migran los datos de tu billetera anterior a esta nueva versión de Bitkit. Esto normalmente tarda menos de un minuto. Migración de billetera MIGRANDO\n<accent>BILLETERA</accent> - Saldo movido de gastos a ahorros - Razón: %s - Canal cerrado Canal de notificación para el servicio en segundo plano del nodo Lightning Notificación del nodo Lightning - El canal no se pudo abrir en segundo plano - Apertura del canal fallida - Canal abierto - Pendiente - Listo para enviar - Error de Lightning - Pago fallido - Por favor, inténtalo de nuevo Recibido %s Pago recibido - Bitkit está ejecutándose en segundo plano para que puedas recibir pagos Lightning + Bitkit está ejecutándose en segundo plano para que puedas recibir pagos y solicitudes Detener app Avanzado Configuración avanzada @@ -600,6 +596,7 @@ Contactos Perfil Ajustes + Datos de salida de Spark Etiquetas Registro de transacciones Impulsos y transferencias @@ -613,16 +610,16 @@ Haga una copia de seguridad de su cartera Copias de seguridad Personalizar en Ajustes de Bitkit en Android - Los pagos en segundo plano están desactivados porque has denegado las notificaciones. - Los pagos en segundo plano están activados. Puedes recibir fondos incluso cuando la app está cerrada (si tu dispositivo está conectado a internet). - Activa las notificaciones para recibir pagos, incluso cuando tu app Bitkit está cerrada. - RECIBE PAGOS\n<accent>PASIVAMENTE</accent> + Las notificaciones están desactivadas porque las rechazaste. + Las notificaciones están activadas. Bitkit puede recordarte tus suscripciones y solicitudes de pago. + Activa las notificaciones para recibir recordatorios de tus suscripciones y solicitudes de pago. + Mantente\n<accent>informado</accent> Notificaciones Apagado Encendido Configurar en segundo plano - Recibir pagos cuando Bitkit está cerrado - Pagos en segundo plano + Permitir notificaciones + Notificaciones Encuentra coincidencias exactas de monto para minimizar el cambio Branch and Bound Selección aleatoria para mayor privacidad @@ -769,15 +766,27 @@ Pendiente En cola Estatus + Movido a USDT + Convertido a bitcoin + Convertido a dólares Dirección de destino Monto El puente requiere atención + Mueve dólares de Spark a tu billetera USDT en Arbitrum. El proveedor del puente fija la comisión. Puente Referencia del puente Los USDT se devolvieron a su billetera. Consulte los detalles para ver el monto reembolsado. + A USDT Su pago está pendiente. Puede seguir la confirmación y la entrega entre redes en Actividad. Permita el acceso a la cámara para escanear códigos QR de pago USDT. Confirmado + La conversión no se completó. Inténtalo de nuevo en un momento. + Comisión + Pagas + Recibes (est.) + Convertir + Obtener bitcoin + Obtener dólares Copiar detalles Entregando Entregando USDT @@ -818,9 +827,11 @@ Active USDT para continuar con este pago o solicitud. ¿Activar USDT? Ingrese una dirección válida para la red seleccionada. + Ingresa una dirección de Spark o una factura de Spark para este token. Ingrese un monto válido con hasta 6 decimales. No se pudo hacer una copia de seguridad de este pago. Se reintentará cuando la copia de seguridad esté disponible. No tiene suficientes USDT para cubrir el monto y las comisiones. + No tienes suficientes dólares en Spark para este monto. Monto demasiado bajo para cubrir los costos del puente. Pruebe con un monto mayor. Configure la fecha y la hora de su dispositivo en automático y vuelva a intentarlo. USDT no está disponible en este momento. Vuelva a intentarlo más tarde. @@ -854,6 +865,7 @@ Pendiente Monto Dirección copiada al portapapeles.\nAsegúrate de enviar USDT solo a través de la red seleccionada. + Factura copiada al portapapeles. A recibir (est.) Comisiones Comisiones estimadas @@ -862,6 +874,7 @@ Red USDT Recibir dólares Recibido + Dólares recibidos Red de recepción Recibiendo USDT Destinatario recibe @@ -871,9 +884,11 @@ Su pago no se completó. Consulte Actividad para ver los detalles. Elija la red que usa su destinatario. Enviar USDT + Enviar dólares Red del remitente Enviado USDT enviados + Dólares enviados USDT (Tether) Pago enviado Su pago se está procesando. Puede seguir su estado en Actividad. @@ -1295,6 +1310,7 @@ Falló la verificación de la dirección. Revise la dirección en su dispositivo y vuelva a intentarlo. Este dispositivo contiene una billetera distinta a la que vinculó. Conecte la billetera vinculada o añada esta como una nueva billetera de hardware. Canal no encontrado + Transferir desde billetera HW A ahorros El monto es demasiado bajo para transferirlo a ahorros. Cerrar el canal en su lugar @@ -1318,6 +1334,9 @@ Firme con\n<accent>su dispositivo</accent> Transacción\n<accent>firmada</accent> TRANSFERENCIA LISTA EN %s + Transferencia no disponible + Las transferencias automáticas a tu saldo de gasto ya no están disponibles. Usa tu propio nodo o configura una conexión con otro proveedor. + <accent>Configuración manual</accent>\nnecesaria Asegúrese de tener una conexión a internet estable. Los datos podrían mostrarse incorrectamente mientras se intenta conectar. Problemas de red detectados Este código QR no contiene un SeedQR válido de 12 palabras. @@ -1566,7 +1585,7 @@ Nombre de la suscripción En curso Resumen - Abra Bitkit para revisar un pago de suscripción. + Abra Bitkit para revisar el pago Pago de suscripción pendiente Métodos de pago Por día diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index f83790081a..0c9e190f0a 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -9,8 +9,8 @@ Convidar Pagaments instantanis Gastar - Quan Bitkit està tancat - Rep pagaments + Recordatoris de pagament + Notificacions Configurar un codi PIN Segur Escaneja i paga @@ -154,7 +154,6 @@ Temps d\'actualització de la memòria cau de tarifes Tarifes Forçar transferència - Algunes connexions no s\'han pogut tancar. No es possible transferir els teus fons de tornada a estalvis. Si us plau, intenta-ho de nou. Transferència forçada fallida Els teus fons seran accessibles en ±14 dies. @@ -166,8 +165,6 @@ Utilitza un altre moneder Balanç de despesa La forma més fàcil és utilitzar els teus estalvis de Bitkit o enviar bitcoin des d\'una altra cartera. - Bitkit no ofereix serveis Lightning al teu país, però encara pots connectar-te a altres nodes directament. - Bitkit no ofereix serveis Lightning al teu país, però encara pots connectar-te a altres nodes directament. Finança el teu <accent>saldo de despesa</accent> QR de canal LNURL Configuració manual @@ -232,6 +229,16 @@ En cua per obrir Obertura de connexió Si us plau, espera, la transferència de fons està en curs. Hauria de trigar <accent>±10 minuts.</accent> + Spark + Comissió de reclamació + %1$d/3 confirmacions + Dipòsit + Dipòsits reclamats + Dipòsits en curs + Proveïdor + Spark mitjançant Breez + Sense límit + Aquest saldo es guarda a Spark, gestionat per Lightspark, Breez i Flashnet. Està segur mentre almenys un d’ells actuï amb honestedat. Poden pausar l’accés, i sortir-ne sense ells requereix una sortida on-chain. Gastable Onchain Despesa Tarifa de liquiditat @@ -277,22 +284,11 @@ Si us plau, espera mentre les dades de la teva antiga cartera es migren a aquesta nova versió de Bitkit. Normalment triga menys d\'un minut. Migració de cartera MIGRANT\n<accent>CARTERA</accent> - Saldo mogut de despesa a estalvis - Motiu: %s - Canal tancat Canal de notificació per al servei en primer pla del node Lightning Notificació del node Lightning - El canal no s\'ha pogut obrir en segon pla - Obertura de canal fallida - Canal obert - Pendent - Llest per enviar - Error de Lightning - Pagament fallit - Si us plau, torna-ho a provar Rebut %s Pagament rebut - Bitkit s\'executa en segon pla perquè puguis rebre pagaments Lightning + Bitkit s\'executa en segon pla perquè puguis rebre pagaments i sol·licituds Aturar aplicació Avançat Configuració avançada @@ -593,6 +589,7 @@ Contactes Perfil Configuracions + Dades de sortida de Spark Etiquetes Registre de transaccions Impulsos i transferències @@ -606,16 +603,16 @@ Fes còpia de seguretat de la cartera Còpies de seguretat Personalitza a la configuració de Bitkit d\'Android - Els pagaments en segon pla estan desactivats perquè has denegat les notificacions. - Els pagaments en segon pla estan habilitats. Pots rebre fons fins i tot quan l\'aplicació està tancada (si el teu dispositiu està connectat a Internet). - Activa les notificacions per rebre pagaments, fins i tot quan la teva aplicació Bitkit està tancada. - REP PAGAMENTS\n<accent>PASSIVAMENT</accent> + Les notificacions estan desactivades perquè les has rebutjat. + Les notificacions estan activades. Bitkit et pot recordar les subscripcions i sol·licituds de pagament. + Activa les notificacions per rebre recordatoris de les teves subscripcions i sol·licituds de pagament. + Mantén-te\n<accent>informat</accent> Notificacions Desactivat Activat Configurar en segon pla - Rep pagaments quan Bitkit està tancat - Pagaments en segon pla + Permet les notificacions + Notificacions Troba coincidències d\'import exactes per minimitzar el canvi Branch and Bound Selecció aleatòria per a privacitat @@ -762,15 +759,27 @@ Pendent A la cua Estat + Mogut a USDT + Convertit a bitcoin + Convertit a dòlars Adreça del destinatari Import El pont requereix atenció + Mou dòlars de Spark a la teva cartera USDT a Arbitrum. El proveïdor del pont fixa la comissió. Pont Referència del pont Els USDT s\'han retornat a la teva cartera. Consulta els detalls per veure l\'import retornat. + A USDT El teu pagament està pendent. Pots seguir la confirmació i el lliurament entre xarxes a Activitat. Permet l\'accés a la càmera per escanejar codis QR de pagament USDT. Confirmat + La conversió no s\'ha completat. Torna-ho a provar d\'aquí a un moment. + Comissió + Pagues + Reps (est.) + Convertir + Obtenir bitcoin + Obtenir dòlars Copiar detalls En lliurament Lliurant USDT @@ -811,9 +820,11 @@ Activa USDT per continuar amb aquest pagament o sol·licitud. Activar USDT? Introdueix una adreça vàlida per a la xarxa seleccionada. + Introdueix una adreça de Spark o una factura de Spark per a aquest token. Introdueix un import vàlid amb un màxim de 6 decimals. No es pot fer la còpia de seguretat d\'aquest pagament. Es tornarà a intentar quan la còpia de seguretat estigui disponible. No tens prou USDT per cobrir l\'import i les tarifes. + No tens prou dòlars a Spark per a aquest import. L\'import és massa petit per cobrir els costos del pont. Prova amb un import més gran. Configura la data i l\'hora del dispositiu en mode automàtic i torna-ho a provar. USDT no està disponible ara mateix. Torna-ho a provar més tard. @@ -847,6 +858,7 @@ Pendent Import Adreça copiada al porta-retalls.\nAssegura’t que l’USDT només s’enviï a través de la xarxa seleccionada. + Factura copiada al porta-retalls. Rebràs (est.) Tarifes Tarifes estimades @@ -855,6 +867,7 @@ Xarxa USDT Rebre dòlars Rebut + Dòlars rebuts Xarxa de recepció Rebent USDT El destinatari rep @@ -864,9 +877,11 @@ El teu pagament no s\'ha completat. Consulta Activitat per veure\'n els detalls. Tria la xarxa que utilitza el teu destinatari. Enviar USDT + Envia dòlars Xarxa del remitent Enviat USDT enviats + Dòlars enviats USDT (Tether) Pagament enviat El teu pagament s\'està processant. Pots seguir-ne l\'estat a Activitat. @@ -1288,6 +1303,7 @@ Aquest dispositiu conté una cartera diferent de la que has vinculat. Connecta la cartera vinculada o afegeix aquesta com a nova cartera de maquinari. Canal no trobat Els fons del teu saldo de despesa s\'han transferit als teus estalvis. + Transferir des del moneder HW A estalvis L\'import és massa baix per transferir-lo a estalvis. Tancar el canal @@ -1312,6 +1328,9 @@ Signa amb\n<accent>el teu dispositiu</accent> Transacció\n<accent>signada</accent> TRANSFERÈNCIA LLESTA EN %s + Transferència no disponible + Les transferències automàtiques al teu saldo de despesa ja no estan disponibles. Fes servir el teu propi node o configura una connexió amb un altre proveïdor. + <accent>Configuració manual</accent>\nnecessària Assegura\'t que tens una connexió a Internet estable. És possible que les dades es mostrin incorrectament mentre s\'intenta connectar. S\'han detectat problemes de xarxa Aquest codi QR no conté un SeedQR de 12 paraules vàlid. @@ -1563,7 +1582,7 @@ Nom de la subscripció En curs Resum - Obre Bitkit per revisar un pagament de subscripció. + Obre Bitkit per revisar el pagament Pagament de subscripció pendent Mètodes de pagament Per dia diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index bb7a644e4b..4a47404337 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -9,8 +9,8 @@ Pozvat Okamžité platby Utratit - Když je Bitkit zavřený - Přijímat platby + Připomínky plateb + Oznámení Nastavte PIN kód Zabezpečit Naskenuj a zaplať @@ -157,7 +157,6 @@ Čas aktualizace mezipaměti poplatků Poplatky Vynutit převod - Některá spojení se nepodařilo uzavřít. Nelze převést vaše prostředky zpět do úspor. Zkuste to prosím znovu. Vynucení převodu selhalo. Vaše prostředky budou dostupné do ±14 dnů. @@ -169,8 +168,6 @@ Použijte jinou peněženku Dostupný zůstatek Nejjednodušší možností je použít úspory z Bitkitu nebo poslat bitcoin z jiné peněženky. - Bitkit v současné době neposkytuje služby lightning ve vaší zemi, ale stále se můžete připojit k jiným uzlům přímo. - Bitkit v současné době neposkytuje služby lightning ve vaší zemi, ale stále se můžete připojit k jiným uzlům přímo. Navyšte svůj <accent>disponibilní zůstatek</accent> LNURL kanál QR Manuální nastavení @@ -235,6 +232,16 @@ Ve frontě na otevření Otevírání spojení Počkejte prosím, probíhá převod finančních prostředků. To by mělo trvat <accent>±10 minut.</accent> + Spark + Poplatek za připsání + %1$d/3 potvrzení + Vklad + Připsané vklady + Probíhající vklady + Poskytovatel + Spark přes Breez + Bez limitu + Tento zůstatek je uložen na Spark, který provozují Lightspark, Breez a Flashnet. Je v bezpečí, dokud alespoň jeden z nich jedná poctivě. Mohou pozastavit přístup a odchod bez nich vyžaduje on-chain výstup. Utratitelné onchain Útraty Poplatek za likviditu @@ -280,22 +287,11 @@ Počkejte prosím, než budou vaše stará data peněženky migrována do této nové verze Bitkit. Obvykle to trvá méně než minutu. Migrace peněženky MIGRACE\nPENĚŽENKY - Zůstatek byl přesunut z útrat do úspor - Důvod: %s - Kanál uzavřen Notifikační kanál pro službu Lightning uzlu na pozadí Notifikace Lightning uzlu - Kanál se nepodařilo otevřít na pozadí - Otevření kanálu selhalo - Kanál otevřen - Čekající - Připraveno k odeslání - Chyba Lightning - Platba selhala - Zkuste to prosím znovu Přijato %s Platba přijata - Bitkit běží na pozadí, abyste mohli přijímat Lightning platby + Bitkit běží na pozadí, abyste mohli přijímat platby a žádosti Zastavit aplikaci Pokročilé Pokročilé nastavení @@ -598,6 +594,7 @@ Kontakty Profil Nastavení + Data pro výstup ze Spark Tagy Transakční protokol Posílení a převody @@ -611,16 +608,16 @@ Zálohujte svou peněženku Zálohy dat Přizpůsobit v nastavení Android Bitkit - Platby na pozadí jsou zakázány, protože jste odmítli oznámení. - Platby na pozadí jsou povoleny. Prostředky můžete přijímat i při zavřené aplikaci (pokud je zařízení připojeno k internetu). - Zapněte oznámení, abyste dostávali platby i když je aplikace Bitkit zavřená. - DOSTÁVAT PLATBY\nPASIVNĚ + Oznámení jsou vypnutá, protože jste je zamítli. + Oznámení jsou zapnutá. Bitkit vám může připomínat předplatná a žádosti o platbu. + Zapněte oznámení a dostávejte připomínky předplatných a žádostí o platbu. + Buďte\n<accent>v obraze</accent> Oznámení Vypnuto Zapnuto Nastavit na pozadí - Dostávat platby když je Bitkit zavřený - Platby na pozadí + Povolit oznámení + Oznámení Najde přesné shody částek pro minimalizaci zbytku Branch and Bound Náhodný výběr pro soukromí @@ -766,15 +763,27 @@ Platby Čekající Ve frontě + Přesunuto do USDT + Převedeno na bitcoin + Převedeno na dolary Adresa příjemce Částka Most vyžaduje pozornost + Přesune dolary ze Sparku do tvé peněženky USDT na Arbitru. Poplatek určuje poskytovatel mostu. Most Reference mostu USDT byly vráceny do vaší peněženky. Vrácenou částku najdete v podrobnostech. + Do USDT Vaše platba čeká na vyřízení. Potvrzení a doručení mezi sítěmi můžete sledovat v Aktivitě. Povolte přístup k fotoaparátu pro skenování platebních QR kódů USDT. Potvrzeno + Převod se nedokončil. Zkus to za chvíli znovu. + Poplatek + Zaplatíš + Dostaneš (odhad) + Převést + Získat bitcoin + Získat dolary Kopírovat údaje Doručování Doručování USDT @@ -815,9 +824,11 @@ Pro pokračování v této platbě nebo žádosti zapněte USDT. Zapnout USDT? Zadejte platnou adresu pro vybranou síť. + Zadej adresu Spark nebo fakturu Spark pro tento token. Zadejte platnou částku s nejvýše 6 desetinnými místy. Tuto platbu nelze zálohovat. Záloha se zopakuje, jakmile bude zálohování dostupné. Nemáte dostatek USDT na pokrytí částky a poplatků. + Na Sparku nemáš na tuto částku dost dolarů. Částka je příliš malá na pokrytí nákladů mostu. Zkuste vyšší částku. Nastavte automatické datum a čas zařízení a zkuste to znovu. USDT momentálně není k dispozici. Zkuste to prosím později. @@ -851,6 +862,7 @@ Čekající Částka Adresa byla zkopírována do schránky.\nUjistěte se, že USDT posíláte pouze prostřednictvím vybrané sítě. + Faktura zkopírována do schránky. Obdržíte (odhad) Poplatky Odhadované poplatky @@ -859,6 +871,7 @@ Síť USDT Přijmout dolary Přijato + Dolary přijaty Přijímající síť Přijímání USDT Příjemce obdrží @@ -868,9 +881,11 @@ Vaše platba nebyla dokončena. Podrobnosti najdete v Aktivitě. Vyberte síť, kterou používá váš příjemce. Odeslat USDT + Poslat dolary Síť odesílatele Odesláno Odeslané USDT + Dolary odeslány USDT (Tether) Platba odeslána Vaše platba se zpracovává. Její stav můžete sledovat v Aktivitě. @@ -1292,6 +1307,7 @@ Ověření adresy se nezdařilo. Zkontrolujte adresu na zařízení a zkuste to znovu. Toto zařízení obsahuje jinou peněženku než tu, kterou jste spárovali. Připojte spárovanou peněženku, nebo tuto přidejte jako novou hardwarovou peněženku. Kanál nenalezen + Převod z HW peněženky Do úspor Částka je příliš nízká pro převod do úspor. Místo toho uzavřít kanál @@ -1316,6 +1332,9 @@ Podepište\n<accent>svým zařízením</accent> Transakce\n<accent>podepsána</accent> PŘEVOD PŘIPRAVEN ZA %s + Převod není dostupný + Automatické převody na zůstatek útrat již nejsou k dispozici. Použijte vlastní uzel nebo si nastavte připojení u jiného poskytovatele. + <accent>Vyžadováno ruční</accent>\nnastavení Zajistěte prosím stabilní připojení k internetu. Během pokusu o připojení se data mohou zobrazovat nesprávně. Zjištěny problémy se sítí Tento QR kód neobsahuje platný 12slovný SeedQR. @@ -1565,7 +1584,7 @@ Název předplatného Průběžné Přehled - Otevřete Bitkit a zkontrolujte platbu předplatného. + Otevřete Bitkit a zkontrolujte platbu Splatná platba předplatného Platební metody Za den diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index efc30cca07..da4fb31d71 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -19,8 +19,8 @@ Hilfe bekommen Kaufen Kaufe bitcoin - Wenn Bitkit geschlossen ist - Empfangen + Zahlungserinnerungen + Benachrichtigungen Wallet-Übersicht Diese Funktion wird derzeit entwickelt und ist bald verfügbar. Bald\n<accent>verfügbar</accent> @@ -101,14 +101,22 @@ Abhängig von der Gebühr Abhängig von der Gebühr Abhängig von der Gebühr + Spark + Einlösegebühr + %1$d/3 Bestätigungen + Einzahlung + Eingelöste Einzahlungen + Laufende Einzahlungen + Anbieter + Spark über Breez + Kein Limit + Dieses Guthaben liegt auf Spark, betrieben von Lightspark, Breez und Flashnet. Es ist sicher, solange mindestens einer von ihnen ehrlich handelt. Sie können den Zugang sperren, und ein Ausstieg ohne sie erfordert einen On-Chain-Exit. Spending\n<accent>Balance</accent> Fülle dein Guthaben auf, um sofortige und günstige Transaktionen mit Freunden, Familie und Händlern zu genießen. Los geht\'s Spending Balance Fund your <accent>spending balance</accent> Am einfachsten ist es, deine Bitkit-Ersparnisse zu verwenden oder Bitcoin von einer anderen Wallet zu senden. - Bitkit bietet derzeit keine Lightning-Dienste in deinem Land an, aber du kannst dich trotzdem direkt mit anderen Knotenpunkten verbinden. - Bitkit bietet derzeit keine Lightning-Dienste in deinem Land an, aber du kannst dich trotzdem direkt mit anderen Knotenpunkten verbinden. Von Sparkonto übertragen Anderes Wallet verwenden Ausgabenkonto @@ -251,7 +259,6 @@ Forcierte Übertragung initiiert Dein Guthaben wird in ±14 Tagen zur Verfügung stehen. Forcierte Übertragung fehlgeschlagen - Einige Verbindungen konnten nicht geschlossen werden. Dein Guthaben konnte nicht auf dein Sparkonto übertragen werden. Bitte versuche es erneut. Ausgabenguthaben bereit Du kannst jetzt jedermann, überall, instant bezahlen. @@ -280,22 +287,11 @@ Bitte warte, während deine alten Wallet-Daten auf diese neue Bitkit-Version migriert werden. Das dauert normalerweise weniger als eine Minute. Wallet-Migration WALLET\n<accent>MIGRATION</accent> - Guthaben von Ausgaben- auf Sparkonto verschoben - Grund: %s - Channel geschlossen Benachrichtigungskanal für den Lightning-Node-Vordergrunddienst Lightning-Node-Benachrichtigung - Channel konnte im Hintergrund nicht geöffnet werden - Channel-Öffnung fehlgeschlagen - Channel geöffnet - Ausstehend - Bereit zum Senden - Lightning-Fehler - Zahlung fehlgeschlagen - Bitte versuche es erneut %s empfangen Zahlung empfangen - Bitkit läuft im Hintergrund, damit du Lightning-Zahlungen empfangen kannst + Bitkit läuft im Hintergrund, damit du Zahlungen und Anfragen empfangen kannst App stoppen Bitkit\n<accent>Nutzungs-bedingungen</accent> Nutzungsbedingungen @@ -611,13 +607,13 @@ Quickpay-Schwelle * Bitkit QuickPay unterstützt ausschließlich Zahlungen von deinem Guthaben. QuickPay beschleunigt den Bezahlvorgang, indem gescannte QR-Codes automatisch bezahlt werden. - Hintergrund-Zahlungen - PASSIV\n<accent>EMPFANGEN</accent> - Aktiviere Benachrichtigungen, um Zahlungen zu empfangen, auch wenn deine Bitkit-App geschlossen ist. - Hintergrund-Zahlungen sind aktiviert. Du kannst Gelder empfangen, auch wenn die App geschlossen ist (wenn dein Gerät mit dem Internet verbunden ist). - Hintergrund-Zahlungen sind deaktiviert, da du Benachrichtigungen abgelehnt hast. + Benachrichtigungen + Bleib\n<accent>informiert</accent> + Aktiviere Benachrichtigungen, um an deine Abonnements und Zahlungsanfragen erinnert zu werden. + Benachrichtigungen sind aktiviert. Bitkit kann dich an Abonnements und Zahlungsanfragen erinnern. + Benachrichtigungen sind deaktiviert, weil du sie abgelehnt hast. Benachrichtigungen - Empfange Zahlungen wenn Bitkit geschlossen ist + Benachrichtigungen erlauben In Android Bitkit-Einstellungen anpassen An Aus @@ -651,6 +647,7 @@ Transaktionshistorie Boosts & Übertragungen Einstellungen + Spark-Ausstiegsdaten Widgets Tags Profil @@ -764,15 +761,27 @@ Verbinden Rapid-Gossip-Sync-Server aktualisiert Möglicherweise musst du die App ein- oder zweimal neu starten, damit diese Änderung wirksam wird. + Zu USDT verschoben + In Bitcoin umgewandelt + In Dollar umgewandelt Empfängeradresse Betrag Bridge erfordert Prüfung + Verschiebt Dollar von Spark in deine USDT-Wallet auf Arbitrum. Die Gebühr legt der Bridge-Anbieter fest. Bridge Bridge-Referenz Die USDT wurden an dein Wallet zurückgegeben. Den erstatteten Betrag findest du in den Details. + Zu USDT Deine Zahlung ist ausstehend. Bestätigung und netzwerkübergreifende Zustellung kannst du unter Aktivität verfolgen. Erlaube Kamerazugriff, um QR-Codes für USDT-Zahlungen zu scannen. Bestätigt + Die Umwandlung wurde nicht abgeschlossen. Versuche es gleich noch einmal. + Gebühr + Du zahlst + Du erhältst (ca.) + Umwandeln + Bitcoin erhalten + Dollar erhalten Details kopieren Wird zugestellt USDT wird zugestellt @@ -813,9 +822,11 @@ Aktiviere USDT, um mit dieser Zahlung oder Anforderung fortzufahren. USDT aktivieren? Gib eine gültige Adresse für das ausgewählte Netzwerk ein. + Gib eine Spark-Adresse oder eine Spark-Rechnung für diesen Token ein. Gib einen gültigen Betrag mit bis zu 6 Dezimalstellen ein. Diese Zahlung kann nicht gesichert werden. Die Sicherung wird wiederholt, sobald sie verfügbar ist. Nicht genug USDT für Betrag und Gebühren. + Nicht genug Dollar auf Spark für diesen Betrag. Der Betrag ist zu klein, um die Bridge-Kosten zu decken. Versuche einen höheren Betrag. Stelle Datum und Uhrzeit deines Geräts auf automatisch und versuche es dann erneut. USDT ist derzeit nicht verfügbar. Bitte versuche es später erneut. @@ -849,6 +860,7 @@ Ausstehend Betrag Adresse in die Zwischenablage kopiert.\nStelle sicher, dass USDT nur über das ausgewählte Netzwerk gesendet wird. + Rechnung in die Zwischenablage kopiert. Empfang (ca.) Gebühren Geschätzte Gebühren @@ -857,6 +869,7 @@ USDT-Netzwerk Dollar empfangen Empfangen + Dollar erhalten Empfangsnetzwerk USDT wird empfangen Empfänger erhält @@ -866,9 +879,11 @@ Deine Zahlung wurde nicht abgeschlossen. Details findest du unter Aktivität. Wähle das Netzwerk, das dein Empfänger verwendet. USDT senden + Dollar senden Absendernetzwerk Gesendet USDT gesendet + Dollar gesendet USDT (Tether) Zahlung übermittelt Deine Zahlung wird verarbeitet. Den Status kannst du unter Aktivität verfolgen. @@ -1289,6 +1304,7 @@ Adressüberprüfung fehlgeschlagen. Überprüfe die Adresse auf deinem Gerät und versuche es erneut. Auf diesem Gerät befindet sich ein anderes Wallet als das gekoppelte. Verbinde das gekoppelte Wallet oder füge dieses als neues Hardware-Wallet hinzu. Kanal nicht gefunden + Von HW-Wallet übertragen Zum Sparkonto Der Betrag ist zu niedrig für eine Übertragung auf das Sparkonto. Stattdessen Kanal schließen @@ -1313,6 +1329,9 @@ Mit deinem\n<accent>Gerät signieren</accent> Transaktion\n<accent>signiert</accent> ÜBERTRAGUNG BEREIT IN %s + Übertragung nicht verfügbar + Automatische Übertragungen auf dein Ausgabenkonto sind nicht mehr verfügbar. Nutze deinen eigenen Node oder richte eine Verbindung mit einem anderen Anbieter ein. + <accent>Manuelle Einrichtung</accent>\nerforderlich Bitte stelle sicher, dass du eine stabile Internetverbindung hast. Während des Verbindungsversuchs werden Daten möglicherweise falsch angezeigt. Netzwerkprobleme erkannt Dieser QR-Code enthält keinen gültigen 12-Wörter-SeedQR. @@ -1564,7 +1583,7 @@ Name des Abonnements Laufend Übersicht - Öffne Bitkit, um eine Abonnementzahlung zu prüfen. + Öffne Bitkit, um die Zahlung zu prüfen Abonnementzahlung fällig Zahlungsmethoden Pro Tag diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 3800135d9c..3d6ff4e00a 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -9,8 +9,8 @@ Πρόσκληση Άμεσες πληρωμές Δαπάνη - Όταν το Bitkit είναι κλειστό - Λήψη πληρωμών + Υπενθυμίσεις πληρωμών + Ειδοποιήσεις Καταχωρήστε κωδικό PIN Ασφάλεια Σάρωσε και πλήρωσε @@ -150,7 +150,6 @@ Χρόνος ενημέρωσης cache τελών Τέλη Αναγκαστική μεταφορά - Κάποιες συνδέσεις δεν μπόρεσαν να κλείσουν. Δεν ήταν δυνατή η μεταφορά των κεφαλαίων σου στις αποταμιεύσεις. Δοκίμασε ξανά. Αποτυχία αναγκαστικής μεταφοράς Τα κεφάλαιά σου θα είναι διαθέσιμα σε ±14 ημέρες. @@ -162,8 +161,6 @@ Χρήση άλλου πορτοφολιού Υπόλοιπο δαπανών Ο ευκολότερος τρόπος είναι να χρησιμοποιήσεις τις αποταμιεύσεις Bitkit ή να στείλεις bitcoin από διαφορετικό πορτοφόλι. - Το Bitkit δεν παρέχει προς το παρόν υπηρεσίες Lightning στη χώρα σου, αλλά μπορείς να συνδεθείς απευθείας σε άλλους κόμβους. - Το Bitkit δεν παρέχει προς το παρόν υπηρεσίες Lightning στη χώρα σου, αλλά μπορείς να συνδεθείς απευθείας σε άλλους κόμβους. Χρηματοδότησε το <accent>υπόλοιπο δαπανών</accent> LNURL Channel QR Χειροκίνητη ρύθμιση @@ -228,6 +225,16 @@ Σε ουρά για άνοιγμα Άνοιγμα σύνδεσης Περίμενε, η μεταφορά κεφαλαίων σου είναι σε εξέλιξη. Αυτό θα διαρκέσει <accent>±10 λεπτά.</accent> + Spark + Χρέωση εξαργύρωσης + %1$d/3 επιβεβαιώσεις + Κατάθεση + Εξαργυρωμένες καταθέσεις + Καταθέσεις σε εξέλιξη + Πάροχος + Spark μέσω Breez + Χωρίς όριο + Αυτό το υπόλοιπο φυλάσσεται στο Spark, που λειτουργούν οι Lightspark, Breez και Flashnet. Είναι ασφαλές όσο τουλάχιστον ένας από αυτούς ενεργεί έντιμα. Μπορούν να αναστείλουν την πρόσβαση, και η έξοδος χωρίς αυτούς απαιτεί έξοδο on-chain. Διαθέσιμα onchain Δαπάνες Τέλος ρευστότητας @@ -274,22 +281,11 @@ Περίμενε όσο τα παλιά δεδομένα του πορτοφολιού μεταφέρονται σε αυτή τη νέα έκδοση του Bitkit. Αυτό συνήθως διαρκεί λιγότερο από ένα λεπτό. Μετάβαση πορτοφολιού ΜΕΤΑΒΑΣΗ\n<accent>ΠΟΡΤΟΦΟΛΙΟΥ</accent> - Το υπόλοιπο μετακινήθηκε από δαπάνες σε αποταμιεύσεις - Λόγος: %s - Το κανάλι έκλεισε Κανάλι ειδοποιήσεων για υπηρεσία προσκηνίου κόμβου Lightning Ειδοποίηση κόμβου Lightning - Το κανάλι απέτυχε να ανοίξει στο παρασκήνιο - Αποτυχία ανοίγματος καναλιού - Το κανάλι άνοιξε - Εκκρεμεί - Έτοιμο για αποστολή - Σφάλμα Lightning - Η πληρωμή απέτυχε - Δοκίμασε ξανά Ελήφθησαν %s Ελήφθη πληρωμή - Το Bitkit εκτελείται στο παρασκήνιο για να μπορείς να λαμβάνεις πληρωμές Lightning + Το Bitkit εκτελείται στο παρασκήνιο για να μπορείς να λαμβάνεις πληρωμές και αιτήματα Διακοπή εφαρμογής Για προχωρημένους Ρύθμιση για προχωρημένους @@ -579,6 +575,7 @@ Επαφές Προφίλ Ρυθμίσεις + Δεδομένα εξόδου Spark Ετικέτες Αρχείο συναλλαγών Ενισχύσεις & Μεταφορές @@ -592,16 +589,16 @@ Αντίγραφο ασφαλείας πορτοφολιού Αντίγραφα δεδομένων Προσαρμογή στις ρυθμίσεις Android Bitkit - Οι πληρωμές παρασκηνίου είναι απενεργοποιημένες επειδή αρνήθηκες τις ειδοποιήσεις. - Οι πληρωμές παρασκηνίου είναι ενεργοποιημένες. Μπορείς να λαμβάνεις κεφάλαια ακόμα και όταν η εφαρμογή είναι κλειστή (αν η συσκευή σου είναι συνδεδεμένη στο διαδίκτυο). - Ενεργοποίησε τις ειδοποιήσεις για να λαμβάνεις πληρωμές ακόμα και όταν η εφαρμογή Bitkit είναι κλειστή. - ΛΑΒΕ ΠΛΗΡΩΜΕΣ\n<accent>ΠΑΘΗΤΙΚΑ</accent> + Οι ειδοποιήσεις είναι απενεργοποιημένες, επειδή τις απορρίψατε. + Οι ειδοποιήσεις είναι ενεργές. Το Bitkit μπορεί να σας υπενθυμίζει συνδρομές και αιτήματα πληρωμής. + Ενεργοποιήστε τις ειδοποιήσεις για να λαμβάνετε υπενθυμίσεις για τις συνδρομές και τα αιτήματα πληρωμής σας. + Μείνε\n<accent>ενημερωμένος</accent> Ειδοποιήσεις Ανενεργό Ενεργό Ρύθμιση στο παρασκήνιο - Λήψη πληρωμών όταν το Bitkit είναι κλειστό - Πληρωμές παρασκηνίου + Να επιτρέπονται οι ειδοποιήσεις + Ειδοποιήσεις Βρίσκει ακριβείς αντιστοιχίσεις ποσού για ελαχιστοποίηση ρέστων Branch and Bound Τυχαία επιλογή για απόρρητο @@ -747,15 +744,27 @@ Εκκρεμεί Σε αναμονή Κατάσταση + Μεταφέρθηκε σε USDT + Μετατράπηκε σε bitcoin + Μετατράπηκε σε δολάρια Διεύθυνση παραλήπτη Ποσό Η γέφυρα χρειάζεται προσοχή + Μεταφέρει δολάρια από το Spark στο πορτοφόλι USDT σου στο Arbitrum. Τη χρέωση ορίζει ο πάροχος της γέφυρας. Γέφυρα Αναφορά γέφυρας Τα USDT επιστράφηκαν στο πορτοφόλι σου. Δες τις λεπτομέρειες για το ποσό που επιστράφηκε. + Σε USDT Η πληρωμή σου εκκρεμεί. Μπορείς να παρακολουθείς την επιβεβαίωση και την παράδοση μεταξύ δικτύων στη Δραστηριότητα. Επίτρεψε την πρόσβαση στην κάμερα για σάρωση κωδικών QR πληρωμών USDT. Επιβεβαιώθηκε + Η μετατροπή δεν ολοκληρώθηκε. Δοκίμασε ξανά σε λίγο. + Χρέωση + Πληρώνεις + Λαμβάνεις (εκτ.) + Μετατροπή + Λήψη bitcoin + Λήψη δολαρίων Αντιγραφή λεπτομερειών Σε παράδοση Παράδοση USDT @@ -796,9 +805,11 @@ Ενεργοποίησε το USDT για να συνεχίσεις με αυτή την πληρωμή ή το αίτημα. Ενεργοποίηση USDT; Εισάγαγε έγκυρη διεύθυνση για το επιλεγμένο δίκτυο. + Εισάγε μια διεύθυνση Spark ή ένα τιμολόγιο Spark για αυτό το token. Εισάγαγε έγκυρο ποσό με έως 6 δεκαδικά ψηφία. Δεν ήταν δυνατή η δημιουργία αντιγράφου ασφαλείας αυτής της πληρωμής. Θα γίνει νέα προσπάθεια όταν είναι διαθέσιμο το αντίγραφο ασφαλείας. Δεν υπάρχουν αρκετά USDT για την κάλυψη του ποσού και των τελών. + Δεν υπάρχουν αρκετά δολάρια στο Spark για αυτό το ποσό. Το ποσό είναι πολύ μικρό για να καλύψει τα κόστη της γέφυρας. Δοκίμασε μεγαλύτερο ποσό. Ρύθμισε την ημερομηνία και την ώρα της συσκευής σου σε αυτόματη και δοκίμασε ξανά. Το USDT δεν είναι διαθέσιμο αυτή τη στιγμή. Δοκίμασε ξανά αργότερα. @@ -832,6 +843,7 @@ Εκκρεμεί Ποσό Η διεύθυνση αντιγράφηκε στο πρόχειρο.\nΒεβαιώσου ότι το USDT αποστέλλεται μόνο μέσω του επιλεγμένου δικτύου. + Το τιμολόγιο αντιγράφηκε στο πρόχειρο. Λήψη (εκτίμ.) Τέλη Εκτιμώμενα τέλη @@ -840,6 +852,7 @@ Δίκτυο USDT Λήψη δολαρίων Ελήφθη + Τα δολάρια ελήφθησαν Δίκτυο λήψης Λήψη USDT Ο παραλήπτης λαμβάνει @@ -849,9 +862,11 @@ Η πληρωμή σου δεν ολοκληρώθηκε. Δες τη Δραστηριότητα για λεπτομέρειες. Επίλεξε το δίκτυο που χρησιμοποιεί ο παραλήπτης σου. Αποστολή USDT + Αποστολή δολαρίων Δίκτυο αποστολέα Στάλθηκε Στάλθηκαν USDT + Τα δολάρια στάλθηκαν USDT (Tether) Η πληρωμή υποβλήθηκε Η πληρωμή σου βρίσκεται σε επεξεργασία. Μπορείς να παρακολουθείς την κατάστασή της στη Δραστηριότητα. @@ -1271,6 +1286,7 @@ Το κανάλι δεν βρέθηκε Τα κεφάλαια στο υπόλοιπο δαπανών σας έχουν μεταφερθεί στις αποταμιεύσεις σας. Η Σύνδεση Έκλεισε + Μεταφορά από πορτοφόλι HW Προς αποταμιεύσεις Το ποσό είναι πολύ χαμηλό για μεταφορά στις αποταμιεύσεις. Κλείσιμο καναλιού αντί αυτού @@ -1294,6 +1310,9 @@ Υπόγραψε με\n<accent>τη συσκευή σου</accent> Η συναλλαγή\n<accent>υπογράφηκε</accent> Η ΜΕΤΑΦΟΡΑ ΕΙΝΑΙ ΕΤΟΙΜΗ ΣΕ %s + Μεταφορά μη διαθέσιμη + Οι αυτόματες μεταφορές στο υπόλοιπο δαπανών δεν είναι πλέον διαθέσιμες. Χρησιμοποιήστε τον δικό σας κόμβο ή ρυθμίστε σύνδεση με άλλον πάροχο. + <accent>Απαιτείται χειροκίνητη</accent>\nρύθμιση Βεβαιώσου ότι έχεις σταθερή σύνδεση στο διαδίκτυο. Τα δεδομένα μπορεί να εμφανίζονται λανθασμένα κατά την προσπάθεια σύνδεσης. Εντοπίστηκαν προβλήματα δικτύου Αυτός ο κωδικός QR δεν περιέχει έγκυρο SeedQR 12 λέξεων. @@ -1557,7 +1576,7 @@ Όνομα συνδρομής Σε εξέλιξη Επισκόπηση - Άνοιξε το Bitkit για να ελέγξεις μια πληρωμή συνδρομής. + Άνοιξε το Bitkit για να ελέγξεις την πληρωμή Οφειλόμενη πληρωμή συνδρομής Τρόποι πληρωμής Ανά ημέρα diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index fc09657b40..dcbae7bc6d 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -9,8 +9,8 @@ Invitar Pagos instantáneos Gastar - Cuando Bitkit está cerrado - Recibe pagos + Recordatorios de pago + Notificaciones Establezca un código PIN PROTEGER Escanea y paga @@ -156,7 +156,6 @@ Hora de actualización de caché de tasas Comisiones Fuerzar transferencia - Algunas conexiones no pudieron cerrarse. No se han podido transferir tus fondos a ahorros. Por favor, inténtalo de nuevo. Transferencia forzada falló Sus fondos estarán accesibles en ±14 días. @@ -168,8 +167,6 @@ Usar Otro Monedero Saldo de gastos La forma más fácil es usar sus ahorros en Bitkit o enviar bitcoin desde un monedero diferente. - Bitkit actualmente no ofrece servicios Lightning en tu país, pero aún puedes conectarte directamente a otros nodos. - Bitkit actualmente no ofrece servicios Lightning en tu país, pero aún puedes conectarte directamente a otros nodos. Rellene su <accent>saldo de gastos</accent> QR de canal LNURL Configuración manual @@ -234,6 +231,16 @@ En cola para apertura Abriendo Conexión Por favor espera, tu transferencia de fondos está en curso. Debería tardar <accent>±10 minutos.</accent> + Spark + Comisión de reclamación + %1$d/3 confirmaciones + Depósito + Depósitos reclamados + Depósitos en curso + Proveedor + Spark mediante Breez + Sin límite + Este saldo se guarda en Spark, gestionado por Lightspark, Breez y Flashnet. Está seguro mientras al menos uno de ellos actúe con honestidad. Pueden pausar el acceso, y salir sin ellos requiere una salida on-chain. Gastable en cadena Gasto Comisión de liquidez @@ -280,22 +287,11 @@ Por favor espera mientras se migran los datos de tu antiguo monedero a esta nueva versión de Bitkit. Esto suele tardar menos de un minuto. Migración del monedero MIGRANDO\n<accent>MONEDERO</accent> - Saldo movido de gasto a ahorros - Motivo: %s - Canal cerrado Canal de notificación para el servicio en primer plano del nodo Lightning Notificación del nodo Lightning - Error al abrir canal en segundo plano - Error al abrir canal - Canal abierto - Pendiente - Listo para enviar - Error de Lightning - Pago fallido - Inténtalo de nuevo Recibido %s Pago recibido - Bitkit se está ejecutando en segundo plano para que puedas recibir pagos Lightning + Bitkit se está ejecutando en segundo plano para que puedas recibir pagos y solicitudes Detener aplicación Avanzado Configuración avanzada @@ -597,6 +593,7 @@ Contactos Perfil Ajustes + Datos de salida de Spark Etiquetas Registro de Transacciones Impulsos y transferencias @@ -610,16 +607,16 @@ Respalda tu monedero Copias de seguridad Personalizar en ajustes de Bitkit en Android - Los pagos en segundo plano están desactivados porque has denegado las notificaciones. - Los pagos en segundo plano están activados. Puedes recibir fondos incluso cuando la aplicación está cerrada (si tu dispositivo está conectado a internet). - Activa las notificaciones para recibir pagos, incluso cuando tu aplicación Bitkit está cerrada. - RECIBE PAGOS\n<accent>PASIVAMENTE</accent> + Las notificaciones están desactivadas porque las rechazaste. + Las notificaciones están activadas. Bitkit puede recordarte tus suscripciones y solicitudes de pago. + Activa las notificaciones para recibir recordatorios de tus suscripciones y solicitudes de pago. + Mantente\n<accent>informado</accent> Notificaciones Desactivado Activado Configurar en segundo plano - Recibe pagos cuando Bitkit está cerrado - Pagos en segundo plano + Permitir notificaciones + Notificaciones Encuentra coincidencias exactas para minimizar el cambio Ramificación y acotación Selección aleatoria para privacidad @@ -765,15 +762,27 @@ Pendiente En cola Estatus + Movido a USDT + Convertido a bitcoin + Convertido a dólares Dirección de destino Cantidad El puente requiere atención + Mueve dólares de Spark a tu monedero USDT en Arbitrum. El proveedor del puente fija la comisión. Puente Referencia del puente Los USDT se han devuelto a tu monedero. Consulta los detalles para ver la cantidad reembolsada. + A USDT Tu pago está pendiente. Puedes seguir la confirmación y la entrega entre redes en Actividad. Permite el acceso a la cámara para escanear códigos QR de pago USDT. Confirmado + La conversión no se ha completado. Vuelve a intentarlo en un momento. + Comisión + Pagas + Recibes (est.) + Convertir + Obtener bitcoin + Obtener dólares Copiar detalles Entregando Entregando USDT @@ -814,9 +823,11 @@ Activa USDT para continuar con este pago o solicitud. ¿Activar USDT? Introduce una dirección válida para la red seleccionada. + Introduce una dirección de Spark o una factura de Spark para este token. Introduce una cantidad válida con hasta 6 decimales. No se pudo hacer una copia de seguridad de este pago. Se reintentará cuando la copia de seguridad esté disponible. No tienes suficientes USDT para cubrir la cantidad y las comisiones. + No tienes suficientes dólares en Spark para este importe. Cantidad demasiado pequeña para cubrir los costes del puente. Prueba con una cantidad mayor. Configura la fecha y la hora de tu dispositivo en automático e inténtalo de nuevo. USDT no está disponible en este momento. Inténtalo de nuevo más tarde. @@ -850,6 +861,7 @@ Pendiente Cantidad Dirección copiada al portapapeles.\nAsegúrate de enviar USDT solo a través de la red seleccionada. + Factura copiada al portapapeles. A recibir (est.) Comisiones Comisiones estimadas @@ -858,6 +870,7 @@ Red USDT Recibir dólares Recibido + Dólares recibidos Red de recepción Recibiendo USDT Destinatario recibe @@ -867,9 +880,11 @@ Tu pago no se completó. Consulta Actividad para ver los detalles. Elige la red que usa tu destinatario. Enviar USDT + Enviar dólares Red del remitente Enviado USDT enviados + Dólares enviados USDT (Tether) Pago enviado Tu pago se está procesando. Puedes seguir su estado en Actividad. @@ -1287,6 +1302,7 @@ Este dispositivo contiene un monedero distinto del que vinculaste. Conecta el monedero vinculado o añade este como un nuevo monedero de hardware. Canal no encontrado Los fondos de tu saldo de gasto se han transferido a tus ahorros. + Transferir desde billetera HW A ahorros La cantidad es demasiado baja para transferirla a ahorros. Cerrar el canal en su lugar @@ -1310,6 +1326,9 @@ Firma con\n<accent>tu dispositivo</accent> Transacción\n<accent>firmada</accent> TRANSFERENCIA LISTA EN %s + Transferencia no disponible + Las transferencias automáticas a tu saldo de gasto ya no están disponibles. Usa tu propio nodo o configura una conexión con otro proveedor. + <accent>Configuración manual</accent>\nnecesaria Asegúrate de tener una conexión a internet estable. Los datos pueden mostrarse de forma incorrecta mientras se intenta conectar. Problemas de red detectados Este código QR no contiene un SeedQR válido de 12 palabras. @@ -1561,7 +1580,7 @@ Nombre de la suscripción En curso Resumen - Abre Bitkit para revisar un pago de suscripción. + Abre Bitkit para revisar el pago Pago de suscripción pendiente Métodos de pago Al día diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5072785f9b..7b23e255f1 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -8,8 +8,8 @@ Invitar Pagos instantáneos Gastar - Cuando Bitkit está cerrado - Recibir pagos + Recordatorios de pago + Notificaciones Establezca un código PIN PROTEGER Escanear y pagar @@ -132,10 +132,7 @@ Tasa de comisión Hora de actualización de caché de comisiones Comisiones - Algunas conexiones no pudieron cerrarse. No se pueden transferir los fondos de vuelta a ahorros. Por favor, inténtalo de nuevo. - Bitkit no ofrece actualmente servicios Lightning en tu país, pero puedes conectarte a otros nodos directamente. - Bitkit no ofrece actualmente servicios Lightning en tu país, pero puedes conectarte a otros nodos directamente. QR de Canal LNURL Configuración Manual Escanea un QR para reclamar tu canal LNURL de otro LSP, o elige la configuración manual. @@ -166,6 +163,16 @@ Pago Exitoso En Cola para Apertura Por favor espera, la transferencia de tus fondos está en progreso. Esto debería llevar <accent>±10 minutos.</accent> + Spark + Comisión de reclamo + %1$d/3 confirmaciones + Depósito + Depósitos reclamados + Depósitos en curso + Proveedor + Spark mediante Breez + Sin límite + Este saldo se guarda en Spark, operado por Lightspark, Breez y Flashnet. Está seguro mientras al menos uno de ellos actúe con honestidad. Pueden pausar el acceso, y salir sin ellos requiere una salida on-chain. Gastable onchain Comisión de liquidez Capacidad de\n<accent>recepción</accent> @@ -278,22 +285,11 @@ Por favor espera mientras los datos de tu antiguo monedero migran a esta nueva versión de Bitkit. Esto normalmente tarda menos de un minuto. Migración del Monedero MIGRANDO\n<accent>MONEDERO</accent> - Saldo movido de gastos a ahorros - Motivo: %s - Canal cerrado Canal de notificaciones para el servicio en segundo plano del nodo Lightning Notificación del nodo Lightning - El canal no pudo abrirse en segundo plano - Apertura de canal fallida - Canal abierto - Pendiente - Listo para enviar - Error de Lightning - Pago fallido - Por favor, inténtalo de nuevo Recibido %s Pago Recibido - Bitkit se está ejecutando en segundo plano para que puedas recibir pagos Lightning + Bitkit se está ejecutando en segundo plano para que puedas recibir pagos y solicitudes Detener App Condiciones de uso Al continuar, declaras que has leído y aceptas los términos de uso. @@ -624,6 +620,7 @@ Registro de Conexiones Registro de Transacciones Ajustes + Datos de salida de Spark Widgets Etiquetas Perfil @@ -632,16 +629,16 @@ Fallo de Copia de Seguridad de Datos Copias de seguridad Personalizar en los Ajustes de Bitkit Android - Los pagos en segundo plano están desactivados porque has denegado las notificaciones. - Los pagos en segundo plano están activados. Puedes recibir fondos incluso cuando la aplicación está cerrada (si tu dispositivo está conectado a internet). - Activa las notificaciones para recibir pagos, incluso cuando tu aplicación Bitkit está cerrada. - RECIBE PAGOS\n<accent>PASIVAMENTE</accent> + Las notificaciones están desactivadas porque las rechazaste. + Las notificaciones están activadas. Bitkit puede recordarte tus suscripciones y solicitudes de pago. + Activa las notificaciones para recibir recordatorios de tus suscripciones y solicitudes de pago. + Mantente\n<accent>informado</accent> Notificaciones Desactivado Activado Configurar en segundo plano - Recibir pagos cuando Bitkit está cerrado - Pagos en Segundo Plano + Permitir notificaciones + Notificaciones Encuentra coincidencias de cantidad exacta para minimizar el cambio Branch and Bound Selección aleatoria para privacidad @@ -762,15 +759,27 @@ Conectar Bitkit se ha conectado correctamente al servidor Rapid-Gossip-Sync especificado. Servidor Rapid-Gossip-Sync Actualizado + Movido a USDT + Convertido a bitcoin + Convertido a dólares Dirección de destino Cantidad El puente requiere atención + Mueve dólares de Spark a tu billetera USDT en Arbitrum. El proveedor del puente fija la comisión. Puente Referencia del puente Los USDT se han devuelto a su monedero. Consulte los detalles para ver la cantidad reembolsada. + A USDT Su pago está pendiente. Puede seguir la confirmación y la entrega entre redes en Actividad. Permita el acceso a la cámara para escanear códigos QR de pago USDT. Confirmado + La conversión no se completó. Inténtalo de nuevo en un momento. + Comisión + Pagas + Recibes (est.) + Convertir + Obtener bitcoin + Obtener dólares Copiar detalles Entregando Entregando USDT @@ -811,9 +820,11 @@ Active USDT para continuar con este pago o solicitud. ¿Activar USDT? Introduzca una dirección válida para la red seleccionada. + Introduce una dirección de Spark o una factura de Spark para este token. Introduzca una cantidad válida con hasta 6 decimales. No se pudo hacer una copia de seguridad de este pago. Se reintentará cuando la copia de seguridad esté disponible. No tiene suficientes USDT para cubrir la cantidad y las comisiones. + No tienes suficientes dólares en Spark para este importe. Cantidad demasiado pequeña para cubrir los costes del puente. Pruebe con una cantidad mayor. Configure la fecha y la hora de su dispositivo en automático e inténtelo de nuevo. USDT no está disponible en este momento. Inténtelo de nuevo más tarde. @@ -847,6 +858,7 @@ Pendiente Cantidad Dirección copiada al portapapeles.\nAsegúrate de enviar USDT solo a través de la red seleccionada. + Factura copiada al portapapeles. A recibir (est.) Comisiones Comisiones estimadas @@ -855,6 +867,7 @@ Red USDT Recibir dólares Recibido + Dólares recibidos Red de recepción Recibiendo USDT Destinatario recibe @@ -864,9 +877,11 @@ Su pago no se completó. Consulte Actividad para ver los detalles. Elija la red que usa su destinatario. Enviar USDT + Enviar dólares Red del remitente Enviado USDT enviados + Dólares enviados USDT (Tether) Pago enviado Su pago se está procesando. Puede seguir su estado en Actividad. @@ -1286,6 +1301,7 @@ Canal no encontrado Los fondos de su saldo de gasto han sido transferidos a sus ahorros. Conexión Cerrada + Transferir desde billetera HW A ahorros La cantidad es demasiado baja para transferirla a ahorros. Cerrar el canal en su lugar @@ -1309,6 +1325,9 @@ Firme con\n<accent>su dispositivo</accent> Transacción\n<accent>firmada</accent> TRANSFERENCIA LISTA EN %s + Transferencia no disponible + Las transferencias automáticas a tu saldo de gasto ya no están disponibles. Usa tu propio nodo o configura una conexión con otro proveedor. + <accent>Configuración manual</accent>\nnecesaria Asegúrese de tener una conexión a internet estable. Los datos pueden mostrarse de forma incorrecta mientras se intenta conectar. Problemas de red detectados Este código QR no contiene un SeedQR válido de 12 palabras. @@ -1561,7 +1580,7 @@ Nombre de la suscripción En curso Resumen - Abra Bitkit para revisar un pago de suscripción. + Abra Bitkit para revisar el pago Pago de suscripción pendiente Métodos de pago Al día diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 6948ef3d1e..df3e6baee5 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -19,8 +19,8 @@ Obtenir de l\'aide Acheter Achetez des bitcoins - Soyez rémunéré - Lorsque Bitkit est fermé + Notifications + Rappels de paiement Aperçu du portefeuille Cette fonctionnalité est actuellement en développement et sera bientôt disponible. Bientôt\n<accent>disponible</accent> @@ -100,14 +100,22 @@ Dépend des frais Dépend des frais Dépend des frais + Spark + Frais de réclamation + %1$d/3 confirmations + Dépôt + Dépôts réclamés + Dépôts en cours + Fournisseur + Spark via Breez + Sans limite + Ce solde est détenu sur Spark, géré par Lightspark, Breez et Flashnet. Il est en sécurité tant qu’au moins l’un d’eux agit honnêtement. Ils peuvent suspendre l’accès, et en sortir sans eux nécessite une sortie on-chain. Dépenses \n<accent>Solde</accent> Alimentez votre solde de dépenses pour profiter de transactions instantanées et bon marché avec vos amis, votre famille et les commerçants. Commencer Solde Dépenses Financez votre <accent>solde Dépenses</accent> Le plus simple est d\'utiliser vos économies Bitkit ou d\'envoyer des bitcoins depuis un autre portefeuille. - Bitkit ne fournit pas actuellement de services Lightning dans votre pays, mais vous pouvez toujours vous connecter directement à d\'autres nœuds. - Bitkit ne fournit pas actuellement de services Lightning dans votre pays, mais vous pouvez toujours vous connecter directement à d\'autres nœuds. Transfert depuis l\'épargne Utiliser un autre portefeuille Solde Dépenses @@ -260,7 +268,6 @@ Vos fonds seront accessibles dans un délai de ±14 jours. Échec du transfert forcé Impossible de renvoyer vos fonds sur l\'Épargne. Veuillez réessayer. - Certaines connexions n\'ont pas pu être fermées. Solde Dépenses Vous pouvez désormais payer n\'importe qui, n\'importe où, instantanément. Traitement du paiement @@ -280,22 +287,11 @@ Veuillez patienter pendant la migration de vos anciennes données vers cette nouvelle version de Bitkit. Cela prend généralement moins d\'une minute. Migration du portefeuille MIGRATION\n<accent>DU PORTEFEUILLE</accent> - Solde transféré des dépenses vers l\'épargne - Raison : %s - Canal fermé Canal de notification pour le service de nœud Lightning en arrière-plan Notification du nœud Lightning - Échec de l\'ouverture du canal en arrière-plan - Échec de l\'ouverture du canal - Canal ouvert - En attente - Prêt à envoyer - Erreur Lightning - Échec du paiement - Veuillez réessayer Reçu %s Paiement reçu - Bitkit fonctionne en arrière-plan pour que vous puissiez recevoir des paiements Lightning + Bitkit fonctionne en arrière-plan pour que vous puissiez recevoir des paiements et des demandes Arrêter l\'application Bitkit \n<accent>Conditions d\'utilisation</accent> Conditions d\'utilisation @@ -638,22 +634,23 @@ Journal des transactions Boosts et transferts Réglages + Données de sortie Spark Widgets Tags Profil Contacts Sauvegardes des données Personnaliser dans les paramètres Android de Bitkit - Les paiements en arrière-plan sont désactivés car vous avez refusé les notifications. - Les paiements en arrière-plan sont activés. Vous pouvez recevoir des fonds même lorsque l\'application est fermée (si votre appareil est connecté à Internet). - Activez les notifications pour être payé, même lorsque votre application Bitkit est fermée. - ÊTRE PAYÉ\n<accent>PASSIVEMENT</accent> + Les notifications sont désactivées, car vous les avez refusées. + Les notifications sont activées. Bitkit peut vous rappeler vos abonnements et demandes de paiement. + Activez les notifications pour recevoir des rappels de vos abonnements et demandes de paiement. + Restez\n<accent>informé</accent> Notifications Désactivé Activé Configurer en arrière-plan - Être payé quand Bitkit est fermé - Paiements en arrière-plan + Autoriser les notifications + Notifications Besoin d\'aide ? Signalez votre problème depuis Bitkit, visitez le centre d\'aide, vérifiez le statut ou contactez-nous sur nos canaux sociaux. Rapporter un problème Centre d\'aide @@ -766,15 +763,27 @@ Connecter Mise à jour du serveur Rapid-Gossip-Sync Il se peut que vous deviez redémarrer l\'application une ou deux fois pour que cette modification prenne effet. + Transféré vers USDT + Converti en bitcoin + Converti en dollars Adresse du destinataire Montant Le pont requiert une action + Transfère des dollars de Spark vers ton portefeuille USDT sur Arbitrum. Le fournisseur du pont fixe les frais. Pont Référence du pont Les USDT ont été restitués à votre portefeuille. Consultez les détails pour le montant remboursé. + Vers USDT Votre paiement est en attente. Vous pouvez suivre la confirmation et la livraison inter-réseaux dans Activité. Autorisez l\'accès à la caméra pour scanner les QR codes de paiement USDT. Confirmé + La conversion n\'a pas abouti. Réessaie dans un instant. + Frais + Tu paies + Tu reçois (est.) + Convertir + Obtenir du bitcoin + Obtenir des dollars Copier les détails En livraison Livraison des USDT @@ -815,9 +824,11 @@ Activez USDT pour poursuivre ce paiement ou cette demande. Activer USDT ? Saisissez une adresse valide pour le réseau sélectionné. + Saisis une adresse Spark ou une facture Spark pour ce jeton. Saisissez un montant valide avec 6 décimales au maximum. Impossible de sauvegarder ce paiement. Une nouvelle tentative aura lieu dès que la sauvegarde sera disponible. USDT insuffisants pour couvrir le montant et les frais. + Pas assez de dollars sur Spark pour ce montant. Montant trop faible pour couvrir les coûts du pont. Essayez un montant plus élevé. Réglez la date et l\'heure de votre appareil en automatique, puis réessayez. USDT est actuellement indisponible. Veuillez réessayer plus tard. @@ -851,6 +862,7 @@ En attente Montant Adresse copiée dans le presse-papiers.\nAssurez-vous que les USDT sont envoyés uniquement sur le réseau sélectionné. + Facture copiée dans le presse-papiers. Reçu (est.) Frais Frais estimés @@ -859,6 +871,7 @@ Réseau USDT Recevoir des dollars Reçu + Dollars reçus Réseau de réception Réception d\'USDT Le destinataire reçoit @@ -868,9 +881,11 @@ Votre paiement n\'a pas abouti. Consultez Activité pour plus de détails. Choisissez le réseau utilisé par votre destinataire. Envoyer des USDT + Envoyer des dollars Réseau d\'envoi Envoyé USDT envoyés + Dollars envoyés USDT (Tether) Paiement soumis Votre paiement est en cours de traitement. Vous pouvez suivre son statut dans Activité. @@ -1289,6 +1304,7 @@ Échec de la vérification de l\'adresse. Vérifiez l\'adresse sur votre appareil et réessayez. Cet appareil contient un autre portefeuille que celui que vous avez associé. Connectez le portefeuille associé ou ajoutez celui-ci comme nouveau portefeuille matériel. Canal introuvable + Transfert depuis le portefeuille HW Vers l\'épargne Le montant est trop faible pour être transféré vers l\'épargne. Fermer plutôt le canal @@ -1312,6 +1328,9 @@ Signez avec\n<accent>votre appareil</accent> Transaction\n<accent>signée</accent> TRANSFERT PRÊT DANS %s + Transfert indisponible + Les transferts automatiques vers votre solde de dépenses ne sont plus disponibles. Utilisez votre propre nœud ou configurez une connexion avec un autre fournisseur. + <accent>Configuration manuelle</accent>\nrequise Veuillez vous assurer que vous disposez d\'une connexion internet stable. Les données peuvent s\'afficher incorrectement pendant la tentative de connexion. Problèmes de réseau détectés Ce QR code ne contient pas de SeedQR valide de 12 mots. @@ -1562,7 +1581,7 @@ Nom de l\'abonnement En cours Aperçu - Ouvrez Bitkit pour examiner un paiement d\'abonnement. + Ouvrez Bitkit pour examiner le paiement Paiement d\'abonnement dû Moyens de paiement Par jour diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1689758d4e..dd86366715 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -9,8 +9,8 @@ Invita Pagamenti istantanei Spendi - Quando Bitkit e\' chiuso - Ricevi pagamenti + Promemoria di pagamento + Notifiche Imposta un codice PIN Sicuro Scansiona e paga @@ -155,7 +155,6 @@ Tempo di Aggiornamento Cache Commissioni Commissioni Trasferimento Forzato - Alcune connessioni non sono state chiuse. Impossibile trasferire nuovamente i fondi nei risparmi. Per favore riprova. Trasferimento Forzato Fallito I tuoi fondi saranno accessibili in ±14 giorni. @@ -167,8 +166,6 @@ Usa un altro portafoglio Conto di Spesa Il modo più semplice è usare i tuoi risparmi Bitkit o inviare bitcoin da un portafoglio diverso. - Bitkit attualmente non fornisce servizi Lightning nel tuo paese, ma puoi comunque connetterti direttamente ad altri nodi. - Bitkit attualmente non fornisce servizi Lightning nel tuo paese, ma puoi comunque connetterti direttamente ad altri nodi. Manda fondi al tuo <accent>conto di spesa</accent> QR Canale LNURL Configurazione Manuale @@ -233,6 +230,16 @@ In Coda Per l\'Apertura Apertura della Connessione Attendi, il trasferimento dei fondi è in corso. L\'operazione dovrebbe richiedere <accent>±10 minuti.</accent> + Spark + Commissione di riscatto + %1$d/3 conferme + Deposito + Depositi riscattati + Depositi in corso + Fornitore + Spark tramite Breez + Nessun limite + Questo saldo è custodito su Spark, gestito da Lightspark, Breez e Flashnet. È al sicuro finché almeno uno di loro agisce onestamente. Possono sospendere l’accesso e uscire senza di loro richiede un’uscita on-chain. Spendibile Onchain Spesa Commissione liquidita\' @@ -279,22 +286,11 @@ Attendi mentre i dati del tuo vecchio wallet migrano a questa nuova versione di Bitkit. Questo di solito richiede meno di un minuto. Migrazione Wallet MIGRAZIONE\n<accent>WALLET</accent> - Saldo spostato dalle spese ai risparmi - Motivo: %s - Canale chiuso Canale di notifica per il servizio in primo piano del nodo Lightning Notifica nodo Lightning - Apertura canale in background non riuscita - Apertura canale non riuscita - Canale aperto - In attesa - Pronto per inviare - Errore Lightning - Pagamento fallito - Per favore riprova Ricevuto %s Pagamento Ricevuto - Bitkit e\' in esecuzione in background per permetterti di ricevere pagamenti Lightning + Bitkit è in esecuzione in background per permetterti di ricevere pagamenti e richieste Ferma App Avanzate Impostazioni Avanzate @@ -596,6 +592,7 @@ Contatti Profilo Impostazioni + Dati di uscita Spark Tag Log delle transazioni Boost e Trasferimenti @@ -609,16 +606,16 @@ Esegui il backup del tuo wallet Backup dei dati Personalizza nelle Impostazioni Android di Bitkit - I pagamenti in background sono disabilitati perche\' hai negato le notifiche. - I pagamenti in background sono abilitati. Puoi ricevere fondi anche quando l\'app e\' chiusa (se il dispositivo e\' connesso a internet). - Attiva le notifiche per ricevere pagamenti anche quando l\'app Bitkit e\' chiusa. - RICEVI PAGAMENTI\n<accent>PASSIVAMENTE</accent> + Le notifiche sono disattivate perché le hai rifiutate. + Le notifiche sono attive. Bitkit può ricordarti abbonamenti e richieste di pagamento. + Attiva le notifiche per ricevere promemoria su abbonamenti e richieste di pagamento. + Resta\n<accent>aggiornato</accent> Notifiche Disattivo Attivo Configura in background - Ricevi pagamenti quando Bitkit e\' chiuso - Pagamenti in Background + Consenti notifiche + Notifiche Trova corrispondenze esatte per minimizzare il resto Branch and Bound Selezione casuale per la privacy @@ -765,15 +762,27 @@ In attesa In coda Stato + Spostato in USDT + Convertito in bitcoin + Convertito in dollari Indirizzo destinatario Importo Il bridge richiede attenzione + Sposta dollari da Spark al tuo wallet USDT su Arbitrum. La commissione è fissata dal fornitore del bridge. Bridge Riferimento bridge Gli USDT sono stati restituiti al tuo wallet. Vedi i dettagli per l\'importo rimborsato. + A USDT Il tuo pagamento è in attesa. Puoi seguire la conferma e la consegna tra reti in Attività. Consenti l\'accesso alla fotocamera per scansionare i codici QR di pagamento USDT. Confermato + La conversione non è stata completata. Riprova tra poco. + Commissione + Paghi + Ricevi (stima) + Converti + Ottieni bitcoin + Ottieni dollari Copia Dettagli In consegna Consegna USDT @@ -814,9 +823,11 @@ Attiva USDT per continuare con questo pagamento o richiesta. Attivare USDT? Inserisci un indirizzo valido per la rete selezionata. + Inserisci un indirizzo Spark o una fattura Spark per questo token. Inserisci un importo valido con al massimo 6 decimali. Impossibile eseguire il backup di questo pagamento. Verrà ritentato quando il backup sarà disponibile. USDT insufficienti per coprire importo e commissioni. + Non hai abbastanza dollari su Spark per questo importo. Importo troppo basso per coprire i costi del bridge. Prova con un importo maggiore. Imposta data e ora del dispositivo in automatico, poi riprova. USDT non è al momento disponibile. Riprova più tardi. @@ -850,6 +861,7 @@ In attesa Importo Indirizzo copiato negli appunti.\nAssicurati che USDT venga inviato solo sulla rete selezionata. + Fattura copiata negli appunti. Ricevi (stima) Commissioni Commissioni Stimate @@ -858,6 +870,7 @@ Rete USDT Ricevi Dollari Ricevuto + Dollari ricevuti Rete di ricezione Ricezione USDT Destinatario riceve @@ -867,9 +880,11 @@ Il tuo pagamento non è stato completato. Controlla Attività per i dettagli. Scegli la rete usata dal destinatario. Invia USDT + Invia dollari Rete mittente Inviato USDT Inviati + Dollari inviati USDT (Tether) Pagamento inviato Il tuo pagamento è in elaborazione. Puoi seguirne lo stato in Attività. @@ -1292,6 +1307,7 @@ Questo dispositivo contiene un wallet diverso da quello associato. Connetti il wallet associato o aggiungi questo come nuovo hardware wallet. Canale non trovato I fondi sul tuo saldo di spesa sono stati trasferiti ai tuoi risparmi. + Trasferimento da wallet HW Ai risparmi L\'importo è troppo basso per essere trasferito ai risparmi. Chiudi invece il canale @@ -1315,6 +1331,9 @@ Firma con\n<accent>il tuo dispositivo</accent> Transazione\n<accent>firmata</accent> TRASFERIMENTO PRONTO TRA %s + Trasferimento non disponibile + I trasferimenti automatici al saldo di spesa non sono più disponibili. Usa il tuo nodo o configura una connessione con un altro fornitore. + <accent>Configurazione manuale</accent>\nrichiesta Assicurati di avere una connessione a internet stabile. I dati potrebbero essere mostrati in modo errato durante il tentativo di connessione. Rilevati Problemi di Rete Questo codice QR non contiene un SeedQR valido da 12 parole. @@ -1565,7 +1584,7 @@ Nome abbonamento In corso Panoramica - Apri Bitkit per controllare un pagamento di abbonamento. + Apri Bitkit per controllare il pagamento Pagamento Abbonamento Dovuto Metodi di pagamento Al Giorno diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 2cb52c4df6..e177e52a4c 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -9,8 +9,8 @@ Uitnodiging Directe betalingen Besteed - Als Bitkit gesloten is - Ontvang betaling + Betaalherinneringen + Meldingen Pincode instellen Beveilig Scan en betaal @@ -154,7 +154,6 @@ Vergoedingstarief cache update tijd Vergoedingen Forceer overboeking - Sommige verbindingen konden niet worden gesloten. Kon het geld niet terugboeken naar spaargeld. Probeer het opnieuw. Geforceerde overboeking mislukt Je geld is beschikbaar in ±14 dagen. @@ -166,8 +165,6 @@ Andere wallet gebruiken Bestedingssaldo De makkelijkste optie is om je Bitkit-spaargeld te gebruiken of bitcoin te versturen vanuit een andere wallet. - Bitkit biedt momenteel geen Lightning-services in jouw land, maar je kunt nog steeds rechtstreeks verbinding maken met andere nodes. - Bitkit biedt momenteel geen Lightning-services in jouw land, maar je kunt nog steeds rechtstreeks verbinding maken met andere nodes. Vul je <accent>bestedingssaldo</accent> LNURL Kanaal QR Handmatige configuratie @@ -232,6 +229,16 @@ In wachtrij voor openen Verbinding openen Even geduld, je overboeking is bezig. Dit duurt <accent>±10 minuten.</accent> + Spark + Claimkosten + %1$d/3 bevestigingen + Storting + Geclaimde stortingen + Lopende stortingen + Aanbieder + Spark via Breez + Geen limiet + Dit saldo staat op Spark, beheerd door Lightspark, Breez en Flashnet. Het is veilig zolang ten minste één van hen eerlijk handelt. Zij kunnen de toegang pauzeren, en vertrekken zonder hen vereist een on-chain exit. Besteedbaar onchain Bestedingssaldo Liquiditeitsvergoeding @@ -278,22 +285,11 @@ Even geduld terwijl je oude wallet-gegevens migreren naar deze nieuwe Bitkit-versie. Dit duurt meestal minder dan een minuut. Wallet-migratie WALLET\n<accent>MIGREREN</accent> - Saldo verplaatst van bestedingssaldo naar spaargeld - Reden: %s - Kanaal gesloten Notificatiekanaal voor Lightning node voorgrondservice Lightning node notificatie - Kanaal kon niet op de achtergrond worden geopend - Kanaal openen mislukt - Kanaal geopend - In behandeling - Klaar om te versturen - Lightning-fout - Betaling mislukt - Probeer het opnieuw %s ontvangen Betaling ontvangen - Bitkit draait op de achtergrond zodat je Lightning-betalingen kunt ontvangen + Bitkit draait op de achtergrond zodat je betalingen en verzoeken kunt ontvangen App stoppen Geavanceerd Geavanceerde configuratie @@ -594,6 +590,7 @@ Contacten Profiel Instellingen + Spark-exitgegevens Tags Transactielog Boosts & overboekingen @@ -607,16 +604,16 @@ Back-up maken van je wallet Gegevensback-ups Aanpassen in Android Bitkit-instellingen - Achtergrondbetalingen zijn uitgeschakeld omdat je notificaties hebt geweigerd. - Achtergrondbetalingen zijn ingeschakeld. Je kunt geld ontvangen zelfs als de app gesloten is (als je apparaat verbonden is met internet). - Schakel notificaties in om betaald te worden, zelfs als je Bitkit-app gesloten is. - ONTVANG BETALING\n<accent>PASSIEF</accent> + Meldingen staan uit, omdat je ze hebt geweigerd. + Meldingen staan aan. Bitkit kan je herinneren aan abonnementen en betaalverzoeken. + Zet meldingen aan om herinneringen te krijgen voor je abonnementen en betaalverzoeken. + Blijf\n<accent>op de hoogte</accent> Notificaties Uit Aan Op achtergrond instellen - Ontvang betaling als Bitkit gesloten is - Achtergrondbetalingen + Meldingen toestaan + Meldingen Vindt exacte bedragovereenkomsten om wisselgeld te minimaliseren Branch and Bound Willekeurige selectie voor privacy @@ -761,15 +758,27 @@ Betalingen In behandeling In de wacht + Verplaatst naar USDT + Omgezet naar bitcoin + Omgezet naar dollars Adres ontvanger Bedrag Bridge vereist aandacht + Verplaatst dollars van Spark naar je USDT-wallet op Arbitrum. De bridge-aanbieder bepaalt de kosten. Bridge Bridge-referentie De USDT is teruggestort naar uw wallet. Bekijk de details voor het terugbetaalde bedrag. + Naar USDT Uw betaling is in behandeling. U kunt de bevestiging en de levering naar het andere netwerk volgen in Activiteit. Sta cameratoegang toe om QR-codes van USDT-betalingen te scannen. Bevestigd + De omzetting is niet voltooid. Probeer het zo opnieuw. + Kosten + Je betaalt + Je ontvangt (ca.) + Omzetten + Bitcoin krijgen + Dollars krijgen Details kopiëren Wordt geleverd USDT wordt geleverd @@ -810,9 +819,11 @@ Schakel USDT in om door te gaan met deze betaling of dit verzoek. USDT inschakelen? Voer een geldig adres in voor het geselecteerde netwerk. + Voer een Spark-adres of een Spark-factuur voor deze token in. Voer een geldig bedrag in met maximaal 6 decimalen. Kan deze betaling niet back-uppen. Dit wordt opnieuw geprobeerd zodra de back-up beschikbaar is. Niet genoeg USDT voor het bedrag en de vergoedingen. + Niet genoeg dollars op Spark voor dit bedrag. Bedrag te klein om de bridgekosten te dekken. Probeer een hoger bedrag. Stel de datum en tijd van uw apparaat in op automatisch en probeer het opnieuw. USDT is momenteel niet beschikbaar. Probeer het later opnieuw. @@ -846,6 +857,7 @@ In behandeling Bedrag Adres gekopieerd naar klembord.\nZorg ervoor dat USDT alleen via het geselecteerde netwerk wordt verstuurd. + Factuur gekopieerd naar het klembord. Ontvangst (ca.) Vergoedingen Geschatte vergoedingen @@ -854,6 +866,7 @@ USDT-netwerk Dollars ontvangen Ontvangen + Dollars ontvangen Ontvangstnetwerk USDT wordt ontvangen Ontvanger krijgt @@ -863,9 +876,11 @@ Uw betaling is niet voltooid. Bekijk Activiteit voor details. Kies het netwerk dat uw ontvanger gebruikt. USDT verzenden + Dollars verzenden Netwerk afzender Verzonden USDT verzonden + Dollars verzonden USDT (Tether) Betaling ingediend Uw betaling wordt verwerkt. U kunt de status volgen in Activiteit. @@ -1288,6 +1303,7 @@ Kanaal niet gevonden Het geld op uw bestedingssaldo is overgemaakt naar uw spaargeld. Verbinding Gesloten + Overboeken van HW-wallet Naar spaargeld Bedrag is te laag om naar spaargeld over te boeken. In plaats daarvan kanaal sluiten @@ -1311,6 +1327,9 @@ Onderteken met\n<accent>uw apparaat</accent> Transactie\n<accent>ondertekend</accent> OVERBOEKING KLAAR OVER %s + Overboeking niet beschikbaar + Automatische overboekingen naar je bestedingssaldo zijn niet meer beschikbaar. Gebruik je eigen node of stel een verbinding in met een andere aanbieder. + <accent>Handmatige instelling</accent>\nvereist Zorg voor een stabiele internetverbinding. Gegevens kunnen onjuist worden weergegeven terwijl er verbinding wordt gemaakt. Netwerkproblemen gedetecteerd Deze QR-code bevat geen geldige SeedQR van 12 woorden. @@ -1563,7 +1582,7 @@ Naam abonnement Doorlopend Overzicht - Open Bitkit om een abonnementsbetaling te bekijken. + Open Bitkit om de betaling te bekijken Abonnementsbetaling verschuldigd Betaalmethoden Per dag diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 6b90799070..5d11299514 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -19,8 +19,8 @@ Otrzymaj wsparcie Kup Kup trochę bitcoina - Gdy Bitkit jest zamknięty - Odbieraj płatności + Przypomnienia o płatnościach + Powiadomienia Przegląd portfela Ta funkcja jest w trakcie rozwoju i wkrótce będzie dostępna. Wkrótce\n<accent>dostępne</accent> @@ -102,14 +102,22 @@ Zależy od opłaty Zależy od opłaty Zależy od opłaty + Spark + Opłata za odbiór + %1$d/3 potwierdzeń + Wpłata + Odebrane wpłaty + Wpłaty w toku + Dostawca + Spark przez Breez + Bez limitu + To saldo jest przechowywane na Spark, prowadzonym przez Lightspark, Breez i Flashnet. Jest bezpieczne, dopóki co najmniej jeden z nich działa uczciwie. Mogą wstrzymać dostęp, a wyjście bez nich wymaga wyjścia on-chain. Saldo \n<accent>Wydatków</accent> Zasil swoje saldo wydatków, aby cieszyć się natychmiastowymi i tanimi transakcjami z przyjaciółmi, rodziną i sprzedawcami. Rozpocznij Saldo wydatków Zasil swoje <accent>saldo wydatków</accent> Najłatwiejszą opcją jest użycie oszczędności Bitkit lub wysłanie bitcoinów z innego portfela. - Bitkit nie świadczy obecnie usług Lightning w Twoim kraju, ale nadal możesz łączyć się z innymi węzłami. - Bitkit nie świadczy obecnie usług Lightning w Twoim kraju, ale nadal możesz łączyć się bezpośrednio z innymi węzłami. Prześlij z Oszczędności Użyj innego portfela Saldo do wydawania @@ -252,7 +260,6 @@ Wymuszenie transferu zainicjowane Środki będą dostępne w ciągu ±14 dni. Wymuszenie transferu nie powiodło się - Niektórych połączeń nie można było zamknąć. Nie można przelać środków z powrotem na saldo oszczędności. Proszę spróbować ponownie. Saldo wydatków gotowe Teraz możesz zapłacić każdemu, gdziekolwiek, natychmiast. @@ -282,22 +289,11 @@ Proszę czekać, trwa migracja danych starego portfela do nowej wersji Bitkit. Zwykle trwa to mniej niż minutę. Migracja portfela MIGRACJA\n<accent>PORTFELA</accent> - Saldo przeniesione z wydatków do oszczędności - Powód: %s - Kanał zamknięty Kanał powiadomień dla usługi Lightning w tle Powiadomienie węzła Lightning - Nie udało się otworzyć kanału w tle - Otwarcie kanału nie powiodło się - Kanał otwarty - Oczekujące - Gotowy do wysłania - Błąd Lightning - Płatność nie powiodła się - Proszę spróbować ponownie Otrzymano %s Płatność otrzymana - Bitkit działa w tle, abyś mógł otrzymywać płatności Lightning + Bitkit działa w tle, abyś mógł otrzymywać płatności i prośby Zatrzymaj aplikację Bitkit\n<accent>warunki użytkowania</accent> Warunki użytkowania @@ -637,6 +633,7 @@ Log transakcji Przyśpieszenia i transfery Ustawienia + Dane wyjścia ze Spark Widgety Tagi Profil @@ -644,16 +641,16 @@ W toku Kopie zapasowe danych Dostosuj w ustawieniach Bitkit na Androidzie - Płatności w tle są wyłączone, ponieważ odmówiono powiadomień. - Płatności w tle są włączone. Możesz otrzymywać środki nawet gdy aplikacja jest zamknięta (jeśli Twoje urządzenie jest połączone z internetem). - Włącz powiadomienia, aby otrzymywać płatności nawet gdy Bitkit jest zamknięty. - OTRZYMUJ\n<accent>PASYWNIE</accent> + Powiadomienia są wyłączone, ponieważ ich odmówiono. + Powiadomienia są włączone. Bitkit może przypominać o subskrypcjach i prośbach o płatność. + Włącz powiadomienia, aby otrzymywać przypomnienia o subskrypcjach i prośbach o płatność. + Bądź\n<accent>na bieżąco</accent> Powiadomienia Wył. Wł. Konfiguracja w tle - Otrzymuj płatności gdy Bitkit jest zamknięty - Płatności w tle + Zezwól na powiadomienia + Powiadomienia Znajduje dokładne dopasowania kwot, aby zminimalizować resztę Branch and Bound Losowy wybór dla prywatności @@ -768,15 +765,27 @@ Połącz Rapid-Gossip-Sync zaktualizowany Zmiana może wymagać ponownego uruchomienia aplikacji raz lub dwa razy. + Przeniesiono do USDT + Wymieniono na bitcoin + Wymieniono na dolary Adres odbiorcy Kwota Most wymaga uwagi + Przenosi dolary ze Sparka do Twojego portfela USDT na Arbitrum. Opłatę ustala dostawca mostu. Most Identyfikator mostu USDT zostało zwrócone do Twojego portfela. Zwróconą kwotę znajdziesz w szczegółach. + Do USDT Twoja płatność oczekuje. Potwierdzenie i dostarczenie między sieciami możesz śledzić w Aktywności. Zezwól na dostęp do aparatu, aby skanować kody QR płatności USDT. Potwierdzono + Wymiana nie została ukończona. Spróbuj ponownie za chwilę. + Opłata + Płacisz + Otrzymasz (szac.) + Wymień + Otrzymaj bitcoin + Otrzymaj dolary Kopiuj szczegóły Dostarczanie Dostarczanie USDT @@ -817,9 +826,11 @@ Włącz USDT, aby kontynuować tę płatność lub żądanie. Włączyć USDT? Wprowadź prawidłowy adres dla wybranej sieci. + Wpisz adres Spark lub fakturę Spark dla tego tokena. Wprowadź prawidłową kwotę z maksymalnie 6 miejscami po przecinku. Nie można utworzyć kopii zapasowej tej płatności. Ponowna próba nastąpi, gdy kopia zapasowa będzie dostępna. Za mało USDT na pokrycie kwoty i opłat. + Za mało dolarów na Sparku na tę kwotę. Kwota jest za mała, aby pokryć koszty mostu. Spróbuj większej kwoty. Ustaw automatyczną datę i godzinę na urządzeniu, a następnie spróbuj ponownie. USDT jest obecnie niedostępne. Spróbuj ponownie później. @@ -853,6 +864,7 @@ W toku Kwota Adres skopiowany do schowka.\nUpewnij się, że USDT jest wysyłany tylko przez wybraną sieć. + Faktura skopiowana do schowka. Otrzymasz (szac.) Opłaty Szacowane opłaty @@ -861,6 +873,7 @@ Sieć USDT Otrzymaj dolary Otrzymano + Dolary otrzymane Sieć odbiorcza Odbieranie USDT Odbiorca otrzyma @@ -870,9 +883,11 @@ Twoja płatność nie została zrealizowana. Szczegóły znajdziesz w Aktywności. Wybierz sieć, której używa odbiorca. Wyślij USDT + Wyślij dolary Sieć nadawcy Wysłane Wysłano USDT + Dolary wysłane USDT (Tether) Płatność wysłana Twoja płatność jest przetwarzana. Jej status możesz śledzić w Aktywności. @@ -1292,6 +1307,7 @@ Weryfikacja adresu nie powiodła się. Sprawdź adres na urządzeniu i spróbuj ponownie. To urządzenie zawiera inny portfel niż sparowany. Połącz sparowany portfel lub dodaj ten jako nowy portfel sprzętowy. Nie znaleziono kanału + Prześlij z portfela HW Na oszczędności Kwota jest zbyt niska, aby przesłać ją na oszczędności. Zamiast tego zamknij kanał @@ -1315,6 +1331,9 @@ Podpisz\n<accent>urządzeniem</accent> Transakcja\n<accent>podpisana</accent> TRANSFER GOTOWY ZA %s + Przesłanie niedostępne + Automatyczne przesyłanie środków na saldo wydatków nie jest już dostępne. Użyj własnego węzła lub skonfiguruj połączenie z innym dostawcą. + <accent>Wymagana ręczna</accent>\nkonfiguracja Upewnij się, że masz stabilne połączenie z internetem. Podczas łączenia dane mogą być wyświetlane nieprawidłowo. Wykryto problemy z siecią Ten kod QR nie zawiera prawidłowego 12-wyrazowego SeedQR. @@ -1564,7 +1583,7 @@ Nazwa subskrypcji W toku Przegląd - Otwórz Bitkit, aby przejrzeć płatność za subskrypcję. + Otwórz Bitkit, aby przejrzeć płatność Termin płatności za subskrypcję Metody płatności Dziennie diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 53396ccd4e..c87d19637c 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -9,8 +9,8 @@ Convide Pagamentos instantâneos Pagar - Quando a Bitkit está fechada - Receba pagamentos + Lembretes de pagamento + Notificações Configure um PIN Segurança Escanear e pagar @@ -158,7 +158,6 @@ Atualização do Cache de Taxa Taxas Forçar Transferência - Algumas conexões não puderam ser fechadas. Não foi possível transferir seus fundos de volta para sua poupança. Por favor, tente novamente. Falha na transferência forçada Seus fundos estarão disponíveis em ±14 dias. @@ -170,8 +169,6 @@ Transferir de Outra Carteira Saldo de Gastos A forma mais fácil é usar suas economias do Bitkit ou enviar bitcoin de uma carteira diferente. - Bitkit não fornece serviços de Lightning no seu paús atualmente, mas você ainda pode se conectar diretamente a outros nodes - Bitkit não fornece serviços de Lightning no seu paús atualmente, mas você ainda pode se conectar diretamente a outros nodes Transfira para seu <accent>saldo de gastos</accent> QR Canal LNURL Setup Manual @@ -236,6 +233,16 @@ Na Fila Para Abertura Abrindo Conexão Aguarde, sua transferência de fundos está em andamento. Isso deve levar <accent>±10 minutos.</accent> + Spark + Taxa de resgate + %1$d/3 confirmações + Depósito + Depósitos resgatados + Depósitos em andamento + Provedor + Spark via Breez + Sem limite + Este saldo fica na Spark, operada pela Lightspark, Breez e Flashnet. Ele está seguro enquanto pelo menos um deles agir com honestidade. Eles podem pausar o acesso, e sair sem eles exige uma saída on-chain. Disponível Onchain Gastos Taxa de liquidez @@ -282,22 +289,11 @@ Aguarde enquanto os dados da sua carteira antiga são migrados para esta nova versão da Bitkit. Isso geralmente leva menos de um minuto. Migração da Carteira MIGRANDO\n<accent>CARTEIRA</accent> - Saldo movido de gastos para poupança - Motivo: %s - Canal fechado Canal de notificação para serviço em primeiro plano do nó Lightning Notificação do nó Lightning - Falha ao abrir canal em segundo plano - Falha ao abrir canal - Canal aberto - Pendente - Pronto para enviar - Erro Lightning - Pagamento falhou - Tente novamente Recebido %s Pagamento Recebido - Bitkit está executando em segundo plano para que você possa receber pagamentos Lightning + Bitkit está executando em segundo plano para que você possa receber pagamentos e solicitações Parar Aplicativo Avançado Configuração Avançada @@ -601,6 +597,7 @@ Contatos Perfil Ajustes + Dados de saída do Spark Tags Histórico de transações Boosts e transferências @@ -614,16 +611,16 @@ Faça backup da sua carteira Backups de dados Personalizar nas Configurações do Bitkit Android - Pagamentos em segundo plano estão desativados porque você negou notificações. - Pagamentos em segundo plano estão ativados. Você pode receber fundos mesmo quando o app está fechado (se seu dispositivo estiver conectado à internet). - Ative as notificações para receber pagamentos, mesmo quando o app Bitkit está fechado. - RECEBA\n<accent>PASSIVAMENTE</accent> + As notificações estão desativadas porque você as recusou. + As notificações estão ativadas. O Bitkit pode lembrar você de assinaturas e solicitações de pagamento. + Ative as notificações para receber lembretes das suas assinaturas e solicitações de pagamento. + Fique\n<accent>por dentro</accent> Notificações Desligado Ligado Configurar em segundo plano - Receba pagamentos quando a Bitkit está fechada - Pagamentos em Segundo Plano + Permitir notificações + Notificações Encontra correspondências exatas para minimizar troco Branch and Bound Seleção aleatória para privacidade @@ -769,15 +766,27 @@ Pagamentos Pendente Na Fila + Movido para USDT + Convertido em bitcoin + Convertido em dólares Endereço do destinatário Valor A ponte precisa de atenção + Move dólares do Spark para sua carteira USDT na Arbitrum. O provedor da ponte define a taxa. Ponte Referência da ponte O USDT foi devolvido à sua carteira. Veja os detalhes para conferir o valor reembolsado. + Para USDT Seu pagamento está pendente. Você pode acompanhar a confirmação e a entrega entre redes em Atividade. Permita o acesso à câmera para escanear códigos QR de pagamento USDT. Confirmado + A conversão não foi concluída. Tente novamente em instantes. + Taxa + Você paga + Você recebe (est.) + Converter + Obter bitcoin + Obter dólares Copiar Detalhes Entregando Entregando USDT @@ -818,9 +827,11 @@ Ative o USDT para continuar com este pagamento ou solicitação. Ativar USDT? Digite um endereço válido para a rede selecionada. + Insira um endereço Spark ou uma fatura Spark para este token. Digite um valor válido com até 6 casas decimais. Não foi possível fazer backup deste pagamento. Uma nova tentativa será feita quando o backup estiver disponível. USDT insuficiente para cobrir o valor e as taxas. + Dólares insuficientes no Spark para este valor. Valor muito baixo para cobrir os custos da ponte. Tente um valor maior. Configure a data e a hora do dispositivo como automáticas e tente novamente. O USDT está indisponível no momento. Tente novamente mais tarde. @@ -854,6 +865,7 @@ Pendente Valor Endereço copiado para a área de transferência.\nCertifique-se de enviar USDT apenas pela rede selecionada. + Fatura copiada para a área de transferência. Receber (est.) Taxas Taxas Estimadas @@ -862,6 +874,7 @@ Rede USDT Receber Dólares Recebido + Dólares recebidos Rede de recebimento Recebendo USDT Destinatário recebe @@ -871,9 +884,11 @@ Seu pagamento não foi concluído. Confira os detalhes em Atividade. Escolha a rede que o seu destinatário usa. Enviar USDT + Enviar dólares Rede do remetente Enviado USDT Enviado + Dólares enviados USDT (Tether) Pagamento iniciado Seu pagamento está sendo processado. Você pode acompanhar o status em Atividade. @@ -1294,6 +1309,7 @@ Falha na verificação do endereço. Confira o endereço no seu dispositivo e tente novamente. Este dispositivo contém uma carteira diferente da que você pareou. Conecte a carteira pareada ou adicione esta como uma nova carteira de hardware. Canal não encontrado + Transferir da carteira HW Para poupança O valor é baixo demais para transferir para a poupança. Fechar o canal em vez disso @@ -1317,6 +1333,9 @@ Assine com\n<accent>seu dispositivo</accent> Transação\n<accent>assinada</accent> TRANSFERÊNCIA PRONTA EM %s + Transferência indisponível + As transferências automáticas para seu saldo de gastos não estão mais disponíveis. Use seu próprio nó ou configure uma conexão com outro provedor. + <accent>Configuração manual</accent>\nnecessária Verifique se você tem uma conexão estável com a internet. Os dados podem ser exibidos incorretamente enquanto tentamos conectar. Problemas de Rede Detectados Este código QR não contém um SeedQR válido de 12 palavras. @@ -1565,7 +1584,7 @@ Nome da assinatura Em andamento Visão Geral - Abra a Bitkit para revisar um pagamento de assinatura. + Abra a Bitkit para revisar o pagamento Pagamento de Assinatura Pendente Formas de pagamento Por Dia diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 33a4fed7a7..59b33927c5 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -19,8 +19,8 @@ Peça ajuda Comprar Compre bitcoin - Quando a Bitkit estiver fechada - Receba pagamentos + Lembretes de pagamento + Notificações Visão geral da carteira Este recurso está em desenvolvimento e estará disponível em breve. Em\n<accent>breve</accent> @@ -97,14 +97,22 @@ Depende da taxa Depende da taxa Depende da taxa + Spark + Taxa de resgate + %1$d/3 confirmações + Depósito + Depósitos resgatados + Depósitos em curso + Fornecedor + Spark via Breez + Sem limite + Este saldo é mantido na Spark, operada pela Lightspark, Breez e Flashnet. Está seguro enquanto pelo menos um deles agir com honestidade. Podem suspender o acesso, e sair sem eles exige uma saída on-chain. Saldo de\n<accent>Gastos</accent> Use seu saldo de gastos para desfrutar de transações instantâneas e baratas com amigos, familiares e comerciantes. Começar Saldo de Gastos Transfira para seu <accent>saldo de gastos</accent> A forma mais fácil é usar as suas poupanças Bitkit ou enviar bitcoin de uma carteira diferente. - Bitkit não fornece serviços de Lightning no seu paús atualmente, mas você ainda pode se conectar diretamente a outros nodes - Bitkit não fornece serviços de Lightning no seu paús atualmente, mas você ainda pode se conectar diretamente a outros nodes Transferir do Saldo de Poupança Transferir de Outra Carteira Saldo de Gastos @@ -267,7 +275,6 @@ Máximo do HTLC de Entrada Mínimo do HTLC de Entrada Altura do Arquivo do Monitor de Canal - Algumas conexões não puderam ser fechadas. TRANSFERÊNCIA EM ANDAMENTO Começar Configure o seu saldo de despesas para desfrutar de transações instantâneas e económicas com amigos, familiares e comerciantes. @@ -275,22 +282,11 @@ Por favor aguarde enquanto seus dados da carteira antiga migram para esta nova versão da Bitkit. Isso geralmente leva menos de um minuto. Migração da Carteira MIGRANDO\n<accent>CARTEIRA</accent> - Saldo movido de gastos para poupança - Motivo: %s - Canal fechado Canal de notificação para o serviço em primeiro plano do nó Lightning Notificação do nó Lightning - Canal falhou ao abrir em segundo plano - Falha na abertura do canal - Canal aberto - Pendente - Pronto para enviar - Erro Lightning - Pagamento falhou - Por favor, tente novamente Recebido %s Pagamento Recebido - A Bitkit está rodando em segundo plano para você receber pagamentos Lightning + A Bitkit está a ser executada em segundo plano para poder receber pagamentos e pedidos Parar App Bitkit\n<accent>termos de uso</accent> Termos de uso @@ -614,6 +610,7 @@ Histórico de transações Boosts e transferências Ajustes + Dados de saída do Spark Widgets Tags Perfil @@ -728,22 +725,40 @@ Servidor de Rapid-Gossip-Sync Atualizado Pode ser necessário reiniciar o aplicativo uma ou duas vezes para que esta alteração seja aplicada. Personalizar nas Configurações da Bitkit no Android - Os pagamentos em segundo plano estão desativados porque você negou as notificações. - Pagamentos em segundo plano estão ativados. Você pode receber fundos mesmo quando o app está fechado (se seu dispositivo estiver conectado à internet). - Ative as notificações para receber pagamentos, mesmo quando a Bitkit está fechada. - RECEBA\n<accent>PASSIVAMENTE</accent> + As notificações estão desativadas porque as recusou. + As notificações estão ativadas. A Bitkit pode lembrá-lo de subscrições e pedidos de pagamento. + Ative as notificações para receber lembretes das suas subscrições e pedidos de pagamento. + Mantenha-se\n<accent>informado</accent> Notificações Desativado Ativado Configurar em segundo plano - Receba pagamentos com a Bitkit fechada - Pagamentos em Segundo Plano + Permitir notificações + Notificações Encontra correspondências exatas para minimizar troco Branch and Bound Seleção aleatória para privacidade Single Random Draw Configurações do sistema Idioma + Movido para USDT + Convertido em bitcoin + Convertido em dólares + Move dólares do Spark para a tua carteira USDT na Arbitrum. O fornecedor da ponte define a taxa. + Para USDT + A conversão não foi concluída. Tenta novamente daqui a pouco. + Taxa + Pagas + Recebes (est.) + Converter + Obter bitcoin + Obter dólares + Introduz um endereço Spark ou uma fatura Spark para este token. + Dólares insuficientes no Spark para este montante. + Fatura copiada para a área de transferência. + Dólares recebidos + Enviar dólares + Dólares enviados Carteira Atividade Contatos @@ -1263,6 +1278,7 @@ Canal não encontrado Os fundos do seu saldo de gastos foram transferidos para a sua poupança. Ligação fechada + Transferir da carteira HW Para a poupança O montante é demasiado baixo para transferir para a poupança. Fechar o canal @@ -1286,6 +1302,9 @@ Assine com\n<accent>o seu dispositivo</accent> Transação\n<accent>assinada</accent> TRANSFERÊNCIA PRONTA EM %s + Transferência indisponível + As transferências automáticas para o seu saldo de gastos já não estão disponíveis. Use o seu próprio nó ou configure uma ligação com outro fornecedor. + <accent>Configuração manual</accent>\nnecessária Confirme que tem uma ligação à internet estável. Os dados podem ser apresentados incorretamente enquanto tenta ligar. Problemas de rede detetados Este código QR não contém um SeedQR válido de 12 palavras. @@ -1551,7 +1570,7 @@ Nome da subscrição Em curso Vista geral - Abra a Bitkit para rever um pagamento de subscrição. + Abra a Bitkit para rever o pagamento Pagamento de subscrição pendente Métodos de pagamento Por dia diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 1b60527452..61a5a0669d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -9,8 +9,8 @@ Пригласить Мгновенные платежи Тратить - Когда Bitkit закрыт - Получать платежи + Напоминания об оплате + Уведомления Установите PIN-код Безопасность Сканируйте и платите @@ -166,7 +166,6 @@ Время обновления кэша комиссии Комиссии Принудительный Перевод - Некоторые соединения не удалось закрыть. Не удалось перевести ваши средства обратно в сбережения. Пожалуйста, попробуйте снова. Неудачный Принудительный Перевод Ваши средства будут доступны через ±14 дней. @@ -178,8 +177,6 @@ Использовать Другой Кошелёк Баланс Расходов Самый простой вариант — использовать ваши сбережения Bitkit или отправить биткоин с другого кошелька. - Bitkit в настоящее время не предоставляет услуги Lightning в вашей стране, но вы всё ещё можете подключаться к другим нодам напрямую. - Bitkit в настоящее время не предоставляет услуги Lightning в вашей стране, но вы всё ещё можете подключаться к другим нодам напрямую. Пополните свой <accent>Баланс Расходов</accent> QR LNURL Канала Ручная Настройка @@ -248,6 +245,16 @@ В Очереди на Открытие Открытие Соединения Пожалуйста, подождите, ваш перевод средств выполняется. Это займёт <accent>±10 минут.</accent> + Spark + Комиссия за зачисление + %1$d/3 подтверждений + Депозит + Зачисленные депозиты + Депозиты в обработке + Провайдер + Spark через Breez + Без ограничений + Этот баланс хранится в Spark, которым управляют Lightspark, Breez и Flashnet. Он в безопасности, пока хотя бы один из них действует честно. Они могут приостановить доступ, а выход без них требует ончейн-вывода. Доступно Onchain Расходы Комиссия за Ликвидность @@ -295,22 +302,11 @@ Миграция кошелька МИГРАЦИЯ <accent>КОШЕЛЬКА</accent> - Баланс перемещён с расходов на сбережения - Причина: %s - Канал закрыт Канал уведомлений для фоновой службы Lightning ноды Уведомление Lightning ноды - Не удалось открыть канал в фоновом режиме - Не удалось открыть канал - Канал открыт - В ожидании - Готов к отправке - Ошибка Lightning - Платёж не удался - Пожалуйста, попробуйте снова Получено %s Платёж получен - Bitkit работает в фоновом режиме, чтобы вы могли получать Lightning платежи + Bitkit работает в фоновом режиме, чтобы вы могли получать платежи и запросы Остановить приложение Дополнительно Дополнительно @@ -616,6 +612,7 @@ Контакты Профиль Настройки + Данные выхода из Spark Теги Журнал Транзакций Ускорения и Переводы @@ -629,17 +626,16 @@ Создать Резервную Копию Кошелька Резервные копии данных Настроить в настройках Android Bitkit - Фоновые платежи отключены, потому что вы отклонили уведомления. - Фоновые платежи включены. Вы можете получать средства, даже когда приложение закрыто (если ваше устройство подключено к интернету). - Включите уведомления, чтобы получать платежи, даже когда приложение Bitkit закрыто. - ПОЛУЧАЙТЕ -<accent>ПАССИВНО</accent> + Уведомления отключены, потому что вы их запретили. + Уведомления включены. Bitkit может напоминать о подписках и запросах на оплату. + Включите уведомления, чтобы получать напоминания о подписках и запросах на оплату. + Будьте\n<accent>в курсе</accent> Уведомления Выкл Вкл Настроить в фоне - Получать платежи при закрытом Bitkit - Фоновые платежи + Разрешить уведомления + Уведомления Находит точные совпадения сумм для минимизации сдачи Branch and Bound Случайный выбор для приватности @@ -787,15 +783,27 @@ В ожидании В очереди Статус + Переведено в USDT + Обменено на биткоин + Обменено на доллары Адрес получателя Сумма Мост требует внимания + Переводит доллары из Spark в ваш кошелёк USDT в Arbitrum. Комиссию устанавливает провайдер моста. Мост ID операции моста USDT возвращены в ваш кошелёк. Сумма возврата указана в деталях. + В USDT Ваш платёж в ожидании. Подтверждение и доставку между сетями можно отслеживать в Активности. Разрешите доступ к камере, чтобы сканировать QR-коды платежей USDT. Подтверждено + Обмен не завершился. Попробуйте ещё раз чуть позже. + Комиссия + Вы платите + Вы получите (≈) + Обменять + Получить биткоин + Получить доллары Копировать Детали Доставка Доставка USDT @@ -836,9 +844,11 @@ Включите USDT, чтобы продолжить этот платёж или запрос. Включить USDT? Введите действительный адрес для выбранной сети. + Введите адрес Spark или счёт Spark для этого токена. Введите корректную сумму, не более 6 знаков после запятой. Не удалось создать резервную копию этого платежа. Попытка повторится, когда резервное копирование станет доступно. Недостаточно USDT для оплаты суммы и комиссий. + Недостаточно долларов в Spark для этой суммы. Сумма слишком мала для покрытия расходов моста. Попробуйте сумму побольше. Включите автоматическую установку даты и времени на устройстве и попробуйте ещё раз. USDT сейчас недоступен. Попробуйте позже. @@ -872,6 +882,7 @@ В ожидании Сумма Адрес скопирован в буфер обмена.\nОтправляйте USDT только через выбранную сеть. + Счёт скопирован в буфер обмена. К получению (≈) Комиссии Примерные Комиссии @@ -880,6 +891,7 @@ Сеть USDT Получить Доллары Получено + Доллары получены Сеть получения Получение USDT Получатель получит @@ -889,9 +901,11 @@ Ваш платёж не выполнен. Подробности смотрите в Активности. Выберите сеть, которую использует получатель. Отправить USDT + Отправить доллары Сеть отправителя Отправлено Отправлено USDT + Доллары отправлены USDT (Tether) Платёж отправлен Ваш платёж обрабатывается. Отслеживать его статус можно в Активности. @@ -1323,6 +1337,7 @@ Bitkit должен увеличить приёмную ёмкость ваше Проверка адреса не удалась. Сверьте адрес на устройстве и попробуйте снова. На этом устройстве другой кошелёк, а не тот, который вы сопрягли. Подключите сопряжённый кошелёк или добавьте этот как новый аппаратный кошелёк. Канал не найден + Перевести с аппаратного кошелька В сбережения Сумма слишком мала для перевода в сбережения. Вместо этого закрыть канал @@ -1346,6 +1361,9 @@ Bitkit должен увеличить приёмную ёмкость ваше Подпишите на\n<accent>устройстве</accent> Транзакция\n<accent>подписана</accent> ПЕРЕВОД БУДЕТ ГОТОВ ЧЕРЕЗ %s + Перевод недоступен + Автоматические переводы на баланс расходов больше недоступны. Используйте собственный узел или настройте подключение к другому провайдеру. + <accent>Требуется ручная</accent>\nнастройка Убедитесь, что у вас стабильное подключение к интернету. Во время подключения данные могут отображаться некорректно. Обнаружены Проблемы с Сетью Этот QR-код не содержит действительный SeedQR из 12 слов. @@ -1593,7 +1611,7 @@ Bitkit должен увеличить приёмную ёмкость ваше Название подписки Бессрочно Обзор - Откройте Bitkit, чтобы проверить платёж по подписке. + Откройте Bitkit, чтобы проверить платёж Пора Оплатить Подписку Способы оплаты В День diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3e6434bbe..c3f40f719b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -38,8 +38,8 @@ Invite Instant payments Spend - When Bitkit is closed - Get paid + Payment reminders + Notifications Set up a PIN code Secure Scan and pay @@ -303,7 +303,6 @@ Fee Rate Cache Update Time Fees Force Transfer - Some connections could not be closed. Unable to transfer your funds back to savings. Please try again. Force Transfer Failed Your funds will be accessible in ±14 days. @@ -313,10 +312,9 @@ Force\n<accent>Transfer</accent> Transfer from Savings Use Other Wallet + Transfer from HW wallet Spending Balance The easiest option is to use your Bitkit savings or send bitcoin from a different wallet. - Bitkit does not currently provide Lightning services in your country, but you can still connect to other nodes directly. - Bitkit does not currently provide Lightning services in your country, but you can still connect to other nodes directly. Fund your <accent>spending balance</accent> LNURL Channel QR Manual Setup @@ -389,6 +387,16 @@ Queued For Opening Opening Connection Please wait, your funds transfer is in progress. This should take <accent>±10 minutes.</accent> + Spark + Claim fee + %1$d/3 confirmations + Deposit + Claimed deposits + Deposits in progress + Provider + Spark via Breez + No limit + This balance is held on Spark, run by Lightspark, Breez and Flashnet. It is safe while at least one of them acts honestly. They can pause access, and leaving without them takes an on-chain exit. Spendable Onchain Spending Your savings cannot cover the liquidity fee for this receiving capacity. Choose a smaller amount. @@ -442,6 +450,9 @@ Your funds have been transferred and your spending balance is ready to use. Funds moved\n<accent>to savings</accent> Funds moved\n<accent>to spending</accent> + Transfer Unavailable + Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider. + <accent>Manual setup</accent>\nrequired Please wait for Bitkit to connect to the payment network (±10 seconds). Wallet Balances Please wait while your old wallet data migrates to this new Bitkit version. This usually takes less than a minute. @@ -449,22 +460,11 @@ Please ensure you have a stable internet connection. Data may show incorrectly while trying to connect. Network Issues Detected MIGRATING\n<accent>WALLET</accent> - Balance moved from spending to savings - Reason: %s - Channel closed Notification channel for Lightning node foreground service Lightning node notification - Channel failed to open in the background - Channel open failed - Channel opened - Pending - Ready to send - Lightning error - Payment failed - Please try again Received %s Payment Received - Bitkit is running in background so you can receive Lightning payments + Bitkit is running in the background so you can receive payments and requests Stop App Advanced Advanced Setup @@ -911,6 +911,7 @@ Contacts Profile Settings + Spark exit data Tags Transaction Log Boosts & Transfers @@ -924,19 +925,19 @@ Latest Backup: {time} Back up your wallet Customize in Android Bitkit Settings - Background payments are disabled, because you have denied notifications. - Background payments are enabled. You can receive funds even when the app is closed (if your device is connected to the internet). + Notifications are off, because you have denied them. + Notifications are on. Bitkit can remind you about subscriptions and payment requests. Enable - Turn on notifications to get paid, even when your Bitkit app is closed. - GET PAID\n<accent>PASSIVELY</accent> + Turn on notifications to get reminders for your subscriptions and payment requests. + Stay\n<accent>notified</accent> Keeping Bitkit active results in more reliable payments. Android may show a persistent notification while this is enabled. Keep Bitkit active in background Notifications Off On Set up in background - Get paid when Bitkit is closed - Background Payments + Allow notifications + Notifications Finds exact amount matches to minimize change Branch and Bound Random selection for privacy @@ -1180,7 +1181,7 @@ Subscription name Ongoing Overview - Open Bitkit to review a subscription payment. + Open Bitkit to review payment Subscription Payment Due Payment methods Payments @@ -1219,15 +1220,27 @@ Weekly Subscription Yearly Yearly Subscription + Moved to USDT + Converted to bitcoin + Converted to dollars Recipient address Amount Bridge needs attention + Moves dollars from Spark to your USDT wallet on Arbitrum. The bridge provider sets the fee. Bridge Bridge reference USDT was returned to your wallet. See details for the refunded amount. + To USDT Your payment is pending. You can track confirmation and cross-network delivery in Activity. Allow camera access to scan USDT payment QR codes. Confirmed + The conversion did not complete. Try again in a moment. + Fee + You pay + You receive (est.) + Convert + Get bitcoin + Get dollars Copy Details Delivering Delivering USDT @@ -1268,9 +1281,11 @@ Enable USDT to continue with this payment or request. Enable USDT? Enter a valid address for the selected network. + Enter a Spark address or a Spark invoice for this token. Enter a valid amount with up to 6 decimal places. Unable to back up this payment. It will be retried when backup is available. Not enough USDT to cover the amount and fees. + Not enough dollars on Spark for this amount. Amount too small to cover bridge costs. Try a larger amount. This route cannot complete the transfer right now. Try another network or try again later. Amount is above this route’s maximum. Try a smaller amount. @@ -1307,6 +1322,7 @@ Pending Amount Address copied to clipboard.\nMake sure USDT is only sent on the selected network. + Invoice copied to clipboard. Receive (est) Fees Estimated Fees @@ -1315,6 +1331,7 @@ USDT Network Receive Dollars Received + Received Dollars Receiving network Receiving USDT Recipient gets @@ -1324,9 +1341,11 @@ Your payment was not completed. Check Activity for details. Choose the network your recipient uses. Send USDT + Send Dollars Sender network Sent Sent USDT + Sent Dollars USDT (Tether) Payment submitted Your payment is being processed. You can track its status in Activity. diff --git a/app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt b/app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt deleted file mode 100644 index a4c692524d..0000000000 --- a/app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt +++ /dev/null @@ -1,248 +0,0 @@ -package to.bitkit.fcm - -import android.Manifest -import android.app.Activity -import android.app.Application -import android.app.Notification -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import androidx.work.ListenableWorker -import androidx.work.WorkerParameters -import androidx.work.workDataOf -import com.synonym.bitkitcore.IcJitEntry -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.lightningdevkit.ldknode.ChannelDetails -import org.lightningdevkit.ldknode.Event -import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.doSuspendableAnswer -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows -import org.robolectric.annotation.Config -import to.bitkit.App -import to.bitkit.CurrentActivity -import to.bitkit.R -import to.bitkit.data.CacheStore -import to.bitkit.domain.commands.ReceivedNotificationContent -import to.bitkit.ext.createChannelDetails -import to.bitkit.ext.mock -import to.bitkit.ext.notificationManager -import to.bitkit.models.BlocktankNotificationType -import to.bitkit.models.NotificationDetails -import to.bitkit.repositories.ActivityRepo -import to.bitkit.repositories.BlocktankRepo -import to.bitkit.repositories.LightningRepo -import to.bitkit.services.NodeEventHandler -import to.bitkit.services.NodeServiceFgState -import to.bitkit.test.BaseUnitTest -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull - -@Config(sdk = [34]) -@RunWith(RobolectricTestRunner::class) -class WakeNodeWorkerTest : BaseUnitTest() { - private val context = ApplicationProvider.getApplicationContext() - private val workerParams = mock() - private val lightningRepo = mock() - private val blocktankRepo = mock() - private val activityRepo = mock() - private val cacheStore = mock() - private val receivedNotificationContent = mock() - private val nodeServiceFgState = NodeServiceFgState() - - private val channelId = "channel-1" - private val receivedTitle by lazy { context.getString(R.string.notification__received__title) } - - @Before - fun setUp() { - whenever(workerParams.inputData).thenReturn( - workDataOf("type" to BlocktankNotificationType.cjitPaymentArrived.name), - ) - - val app = context as Application - Shadows.shadowOf(app).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) - - // Default: app killed (no foreground activity); nodeServiceFgState defaults to not-running - App.currentActivity = CurrentActivity() - } - - @After - fun tearDown() { - App.currentActivity = null - } - - @Test - fun `cjit channel ready delivers rich notification content with fiat`() = test { - val body = $$"Received ₿ 48 064 ($30.79)" - whenever(receivedNotificationContent.build(48_064L)).thenReturn(NotificationDetails(receivedTitle, body)) - val channel = cjitChannel(sats = 48_064) - stubChannel(channel, cjitEntry = IcJitEntry.mock()) - stubStartFiring(channelReadyEvent()) - - val result = worker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - val notification = findNotificationByTitle(receivedTitle) - assertNotNull(notification, "CJIT notification should be delivered when app is killed") - assertEquals(body, notification.extras?.getString(Notification.EXTRA_TEXT)) - verify(activityRepo).insertActivityFromCjit(any(), any()) - } - - @Test - fun `non-cjit channel ready delivers no payment notification`() = test { - val channel = cjitChannel(sats = 45_000) - stubChannel(channel, cjitEntry = null) - stubStartFiring(channelReadyEvent()) - - val result = worker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - assertNull(findNotificationByTitle(receivedTitle), "A non-CJIT channel must not show a payment notification") - verify(activityRepo, never()).insertActivityFromCjit(any(), any()) - verify(cacheStore, never()).setBackgroundReceive(any()) - } - - @Test - fun `cjit channel ready skips notification when foreground service is running`() = test { - nodeServiceFgState.setForegroundServiceRunning(true) - val channel = cjitChannel(sats = 48_064) - stubChannel(channel, cjitEntry = IcJitEntry.mock()) - stubStartFiring(channelReadyEvent()) - - val result = worker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - assertNull(findNotificationByTitle(receivedTitle), "Deduped when the foreground service handles it") - // Defers entirely: the in-process handler owns the insert, so the worker must not win the dedup race - verify(activityRepo, never()).insertActivityFromCjit(any(), any()) - verify(cacheStore, never()).setBackgroundReceive(any()) - } - - @Test - fun `cjit channel ready skips notification when activity already exists`() = test { - val channel = cjitChannel(sats = 48_064) - stubChannel(channel, cjitEntry = IcJitEntry.mock()) - whenever(activityRepo.insertActivityFromCjit(any(), any())).thenReturn(Result.success(false)) - stubStartFiring(channelReadyEvent()) - - val result = worker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - assertNull(findNotificationByTitle(receivedTitle), "A duplicate CJIT channel ready must not notify again") - verify(cacheStore, never()).setBackgroundReceive(any()) - } - - @Test - fun `cjit channel ready skips notification when app is in foreground`() = test { - App.currentActivity?.onActivityStarted(mock()) - val channel = cjitChannel(sats = 48_064) - stubChannel(channel, cjitEntry = IcJitEntry.mock()) - stubStartFiring(channelReadyEvent()) - - val result = worker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - assertNull(findNotificationByTitle(receivedTitle), "Notification is deduped when the in-app UI handles it") - // Defers entirely: the in-app handler owns the insert, so the worker must not win the dedup race - verify(activityRepo, never()).insertActivityFromCjit(any(), any()) - verify(cacheStore, never()).setBackgroundReceive(any()) - } - - @Test - fun `payment received delivers rich notification content with fiat`() = test { - val title = context.getString(R.string.notification__received__title) - val body = $$"Received ₿ 48 064 ($30.79)" - whenever(receivedNotificationContent.build(48_064L)).thenReturn(NotificationDetails(title, body)) - whenever(workerParams.inputData).thenReturn( - workDataOf("type" to BlocktankNotificationType.incomingHtlc.name), - ) - whenever(lightningRepo.stop()).thenReturn(Result.success(Unit)) - stubStartFiring(paymentReceivedEvent(sats = 48_064)) - - val result = worker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - val notification = findNotificationByTitle(title) - assertNotNull(notification, "Payment notification should be delivered when app is killed") - assertEquals(body, notification.extras?.getString(Notification.EXTRA_TEXT)) - } - - @Test - fun `payment received skips notification when foreground service is running`() = test { - nodeServiceFgState.setForegroundServiceRunning(true) - whenever(workerParams.inputData).thenReturn( - workDataOf("type" to BlocktankNotificationType.incomingHtlc.name), - ) - whenever(lightningRepo.stop()).thenReturn(Result.success(Unit)) - stubStartFiring(paymentReceivedEvent(sats = 48_064)) - - val result = worker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - assertNull( - findNotificationByTitle(context.getString(R.string.notification__received__title)), - "Notification is deduped when the foreground service handles it", - ) - // Receive is still cached for the in-app UI to pick up - verify(cacheStore).setBackgroundReceive(any()) - } - - private fun worker() = WakeNodeWorker( - appContext = context, - workerParams = workerParams, - lightningRepo = lightningRepo, - blocktankRepo = blocktankRepo, - activityRepo = activityRepo, - cacheStore = cacheStore, - receivedNotificationContent = receivedNotificationContent, - nodeServiceFgState = nodeServiceFgState, - ) - - private fun channelReadyEvent() = mock { - on { this.channelId } doReturn this@WakeNodeWorkerTest.channelId - } - - private fun paymentReceivedEvent(sats: Long) = Event.PaymentReceived( - paymentId = "payment-1", - paymentHash = "hash-1", - amountMsat = (sats * 1000).toULong(), - customRecords = emptyList(), - ) - - private fun cjitChannel(sats: Long) = createChannelDetails().copy( - channelId = channelId, - outboundCapacityMsat = (sats * 1000).toULong(), - unspendablePunishmentReserve = 0u, - ) - - private suspend fun stubChannel(channel: ChannelDetails, cjitEntry: IcJitEntry?) { - whenever(lightningRepo.getChannels()).thenReturn(listOf(channel)) - whenever(blocktankRepo.getCjitEntry(channel)).thenReturn(cjitEntry) - whenever(activityRepo.insertActivityFromCjit(any(), any())).thenReturn(Result.success(true)) - whenever(lightningRepo.stop()).thenReturn(Result.success(Unit)) - } - - private fun stubStartFiring(event: Event) { - whenever { - lightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), any()) - }.doSuspendableAnswer { - val handler = it.getArgument(5) - handler?.invoke(event) - Result.success(Unit) - } - } - - private fun findNotificationByTitle(title: String): Notification? { - val shadows = Shadows.shadowOf(context.notificationManager) - return shadows.allNotifications.find { it.extras.getString(Notification.EXTRA_TITLE) == title } - } -} diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 5e192dbd06..2049dbfd03 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -27,6 +27,7 @@ import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -61,6 +62,7 @@ import to.bitkit.models.KnownDevice import to.bitkit.models.MetadataBackupV1 import to.bitkit.models.PaykitPaymentStateBackup import to.bitkit.models.PaykitUsdtStateBackup +import to.bitkit.models.SparkExitBackupV1 import to.bitkit.models.TransportType import to.bitkit.models.WalletBackupV1 import to.bitkit.models.WalletScope @@ -110,6 +112,7 @@ class BackupRepoTest : BaseUnitTest() { private val keychain = mock() private val onchainSendAttemptStore = mock() private val transferRepo = mock() + private val sparkRepo = mock() private val vssStoreIdProvider = mock() private val preActivityMetadataRepo = mock() private val lightningService = mock() @@ -185,9 +188,62 @@ class BackupRepoTest : BaseUnitTest() { privatePaykitAddressReservationRepo.reconcileReservedIndexesWithLdk() }.thenReturn(Result.success(Unit)) + whenever { sparkRepo.exitBackup() }.thenReturn(Result.success(null)) + whenever { sparkRepo.restoreExitBackup(any(), any()) }.thenReturn(Result.success(Unit)) + whenever(sparkRepo.exitStateChanged).thenReturn(MutableSharedFlow()) + whenever(sparkRepo.state).thenReturn(MutableStateFlow(SparkState())) + sut = createSut() } + @Test + fun `spark exit backup uploads the exported state and restores it before the wallet`() = test { + val backup = SparkExitBackupV1(createdAt = 1_000, network = "regtest", exitState = "{\"pedigrees\":[]}") + whenever(sparkRepo.exitBackup()).thenReturn(Result.success(backup)) + + sut.triggerBackup(BackupCategory.SPARK_EXIT).getOrThrow() + + val data = argumentCaptor() + verify(vssBackupClient).putObject(eq(BackupCategory.SPARK_EXIT.name), data.capture()) + whenever(vssBackupClient.getObject(BackupCategory.SPARK_EXIT.name)).thenReturn( + Result.success(VssItem(key = BackupCategory.SPARK_EXIT.name, value = data.firstValue, version = 1)) + ) + + sut.performFullRestoreFromLatestBackup().getOrThrow() + + inOrder(sparkRepo, vssBackupClient) { + verify(sparkRepo).restoreExitBackup(backup, 0) + verify(vssBackupClient).getObject(BackupCategory.WALLET.name) + } + } + + @Test + fun `spark exit backup uploads when Spark was ready before observing started`() = test { + val statuses = MutableStateFlow(mapOf(BackupCategory.SPARK_EXIT to BackupItemStatus(synced = 1, required = 1))) + stubBackupStatuses(statuses, CompletableDeferred(Unit)) {} + stubBackupObservers() + whenever(sparkRepo.state).thenReturn(MutableStateFlow(SparkState(isSupported = true, isReady = true))) + val backup = SparkExitBackupV1(createdAt = 1_000, network = "regtest", exitState = "state") + whenever(sparkRepo.exitBackup()).thenReturn(Result.success(backup)) + try { + sut.startObservingBackups() + runCurrent() + advanceTimeBy(6_000) + runCurrent() + + verify(vssBackupClient).putObject(eq(BackupCategory.SPARK_EXIT.name), any()) + } finally { + sut.stopObservingBackups() + } + } + + @Test + fun `spark exit backup uploads nothing while Spark is not connected`() = test { + sut.triggerBackup(BackupCategory.SPARK_EXIT).getOrThrow() + + verify(vssBackupClient, never()).putObject(eq(BackupCategory.SPARK_EXIT.name), any()) + } + @Test fun `wallet backup waits for submission and includes the latest pending state`() = test { val statuses = MutableStateFlow( @@ -2102,6 +2158,7 @@ class BackupRepoTest : BaseUnitTest() { onchainSendAttemptStore = onchainSendAttemptStore, transferRepo = Provider { transferRepo }, lightningRepo = Provider { lightningRepo }, + sparkRepo = sparkRepo, vssStoreIdProvider = vssStoreIdProvider, preActivityMetadataRepo = preActivityMetadataRepo, lightningService = lightningService, diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index a06dcd724b..668af1981a 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1,11 +1,8 @@ package to.bitkit.repositories import app.cash.turbine.test -import com.google.firebase.messaging.FirebaseMessaging import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.FeeRates -import com.synonym.bitkitcore.IBtInfo -import com.synonym.bitkitcore.ILspNode import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.LnurlException import com.synonym.bitkitcore.LnurlPayData @@ -82,10 +79,10 @@ import to.bitkit.services.ElectrumProbeError import to.bitkit.services.ElectrumProbeService import to.bitkit.services.LightningService import to.bitkit.services.LnurlService -import to.bitkit.services.LspNotificationsService import to.bitkit.services.NetworkGraphInfo import to.bitkit.services.NodeEventHandler import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import to.bitkit.utils.AppError import to.bitkit.utils.LdkError import to.bitkit.utils.UrlValidator @@ -120,10 +117,9 @@ class LightningRepoTest : BaseUnitTest() { private lateinit var sut: LightningRepo private val lightningService = mock() + private val sparkRepo = idleSparkRepo() private val settingsStore = mock() private val coreService = mock() - private val lspNotificationsService = mock() - private val firebaseMessaging = mock() private val keychain = mock() private val cacheStore = mock() private val preActivityMetadataRepo = mock() @@ -141,7 +137,7 @@ class LightningRepoTest : BaseUnitTest() { @Before fun setUp() = runBlocking { - whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull())) .thenReturn(Unit) whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) whenever { electrumProbeService.probe(any(), any()) }.thenReturn(Result.success(Unit)) @@ -155,8 +151,6 @@ class LightningRepoTest : BaseUnitTest() { lightningService = lightningService, settingsStore = settingsStore, coreService = coreService, - lspNotificationsService = lspNotificationsService, - firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, cacheStore = cacheStore, @@ -166,6 +160,7 @@ class LightningRepoTest : BaseUnitTest() { vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, electrumProbeService = electrumProbeService, + sparkRepo = sparkRepo, ) } @@ -230,6 +225,7 @@ class LightningRepoTest : BaseUnitTest() { transferRepo = mock(), activityRepo = mock(), hwWalletRepo = hardware, + sparkRepo = sparkRepo, ) } @@ -626,27 +622,24 @@ class LightningRepoTest : BaseUnitTest() { } @Test - fun `start restores the initial state when cancelled during node setup`() = test { + fun `start updates geo block state before the node is up`() = test { stubNodeStatus { false } - whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) - .doSuspendableAnswer { awaitCancellation() } - var result: Result? = null + whenever(lightningService.node).thenReturn(mock()) + whenever(lightningService.start(anyOrNull(), any())).doSuspendableAnswer { awaitCancellation() } + whenever(coreService.isGeoBlocked()).thenReturn(true) - val job = launch { result = sut.start(shouldRetry = false) } + val job = launch { sut.start(shouldRetry = false) } runCurrent() - job.cancelAndJoin() - testScheduler.advanceUntilIdle() - assertNull(result) - assertEquals(NodeLifecycleState.Initializing, sut.lightningState.value.nodeLifecycleState) - verifyBlocking(lightningService, never()) { start(anyOrNull(), any()) } + assertTrue(sut.lightningState.value.isGeoBlocked) + job.cancelAndJoin() } @Test - fun `start restores the initial state when cancelled while trusted peers are fetched`() = test { + fun `start restores the initial state when cancelled during node setup`() = test { stubNodeStatus { false } - val blocktank = coreService.blocktank - whenever(blocktank.info(any())).doSuspendableAnswer { awaitCancellation() } + whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull())) + .doSuspendableAnswer { awaitCancellation() } var result: Result? = null val job = launch { result = sut.start(shouldRetry = false) } @@ -656,14 +649,9 @@ class LightningRepoTest : BaseUnitTest() { assertNull(result) assertEquals(NodeLifecycleState.Initializing, sut.lightningState.value.nodeLifecycleState) - // The cancellation must unwind out of the trusted peers fetch instead of starting a node - verifyBlocking(lightningService, never()) { - setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) - } verifyBlocking(lightningService, never()) { start(anyOrNull(), any()) } } - // Regression #845: a node started by another path between the status check and start must not latch an error @Test fun `start adopts the running node when start throws AlreadyRunning`() = test { var nodeRunning = false @@ -979,7 +967,7 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) verifyBlocking(lightningService, times(2)) { - setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), eq(migration)) + setup(any(), anyOrNull(), anyOrNull(), eq(migration)) } assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) } @@ -1042,7 +1030,7 @@ class LightningRepoTest : BaseUnitTest() { val inOrder = inOrder(lightningService) inOrder.verify(lightningService).stop() - inOrder.verify(lightningService).setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + inOrder.verify(lightningService).setup(any(), anyOrNull(), anyOrNull(), anyOrNull()) inOrder.verify(lightningService).start(anyOrNull(), any()) } @@ -1369,7 +1357,7 @@ class LightningRepoTest : BaseUnitTest() { sut.stop() whenever(lightningService.nodeId).thenReturn(null) whenever(lightningService.node).thenReturn(null) - whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull())) .thenThrow(IllegalStateException("electrum server unreachable")) assertTrue(sut.start(shouldRetry = false).isFailure) @@ -1634,18 +1622,17 @@ class LightningRepoTest : BaseUnitTest() { } @Test - fun `connectToTrustedPeers should fail when node is not running`() = test { - val result = sut.connectToTrustedPeers() + fun `disconnectIdlePeers should fail when node is not running`() = test { + val result = sut.disconnectIdlePeers() assertTrue(result.isFailure) } @Test - fun `connectToTrustedPeers should succeed when node is running`() = test { + fun `start disconnects idle peers instead of connecting to fixed peers`() = test { startNodeForTesting() - whenever(lightningService.connectToTrustedPeers()).thenReturn(Unit) - val result = sut.connectToTrustedPeers() - assertTrue(result.isSuccess) + verifyBlocking(lightningService) { disconnectIdlePeers() } + verifyBlocking(lightningService, never()) { connectPeer(any()) } } @Test @@ -1956,8 +1943,6 @@ class LightningRepoTest : BaseUnitTest() { lightningService = lightningService, settingsStore = settingsStore, coreService = coreService, - lspNotificationsService = lspNotificationsService, - firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, cacheStore = cacheStore, @@ -1967,6 +1952,7 @@ class LightningRepoTest : BaseUnitTest() { vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, electrumProbeService = electrumProbeService, + sparkRepo = sparkRepo, ) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) whenever(coreService.activity).thenReturn(mock()) @@ -2386,12 +2372,13 @@ class LightningRepoTest : BaseUnitTest() { val store = OnchainSendAttemptStore(testDispatcher, keychain, lightningService, kotlin.time.Clock.System) sut = LightningRepo( bgDispatcher = testDispatcher, lightningService = lightningService, settingsStore = settingsStore, - coreService = coreService, lspNotificationsService = lspNotificationsService, - firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, + coreService = coreService, + keychain = keychain, lnurlService = lnurlService, cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, electrumProbeService = electrumProbeService, + sparkRepo = sparkRepo, ) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) val activityService = mock() @@ -2456,12 +2443,13 @@ class LightningRepoTest : BaseUnitTest() { val store = OnchainSendAttemptStore(testDispatcher, keychain, lightningService, kotlin.time.Clock.System) sut = LightningRepo( bgDispatcher = testDispatcher, lightningService = lightningService, settingsStore = settingsStore, - coreService = coreService, lspNotificationsService = lspNotificationsService, - firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, + coreService = coreService, + keychain = keychain, lnurlService = lnurlService, cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, electrumProbeService = electrumProbeService, + sparkRepo = sparkRepo, ) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) val activityService = mock() @@ -2537,12 +2525,13 @@ class LightningRepoTest : BaseUnitTest() { }.whenever(store).broadcastPreparedCandidate(any(), any(), any(), any()) sut = LightningRepo( bgDispatcher = testDispatcher, lightningService = lightningService, settingsStore = settingsStore, - coreService = coreService, lspNotificationsService = lspNotificationsService, - firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, + coreService = coreService, + keychain = keychain, lnurlService = lnurlService, cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, electrumProbeService = electrumProbeService, + sparkRepo = sparkRepo, ) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) val activityService = mock() @@ -2746,12 +2735,6 @@ class LightningRepoTest : BaseUnitTest() { ) } - @Test - fun `registerForNotifications should fail when node is not running`() = test { - val result = sut.registerForNotifications() - assertTrue(result.isFailure) - } - @Test fun `restartWithElectrumServer should setup with new server`() = test { startNodeForTesting() @@ -2765,7 +2748,7 @@ class LightningRepoTest : BaseUnitTest() { val inOrder = inOrder(lightningService) inOrder.verify(lightningService).stop() inOrder.verify(lightningService) - .setup(any(), eq(customServerUrl), anyOrNull(), anyOrNull(), anyOrNull()) + .setup(any(), eq(customServerUrl), anyOrNull(), anyOrNull()) inOrder.verify(lightningService).start(anyOrNull(), any()) assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) } @@ -2806,12 +2789,12 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.node).thenReturn(null) whenever(lightningService.stop()).thenReturn(Unit) // The switch to the new server fails. - whenever(lightningService.setup(any(), eq(badUrl), anyOrNull(), anyOrNull(), anyOrNull())) + whenever(lightningService.setup(any(), eq(badUrl), anyOrNull(), anyOrNull())) .thenThrow(RuntimeException("start failed")) // The background recovery (previous config) blocks until released. val recoveryStarted = CompletableDeferred() val releaseRecovery = CompletableDeferred() - whenever { lightningService.setup(any(), isNull(), isNull(), anyOrNull(), anyOrNull()) } + whenever { lightningService.setup(any(), isNull(), isNull(), anyOrNull()) } .doSuspendableAnswer { recoveryStarted.complete(Unit) releaseRecovery.await() @@ -2842,7 +2825,7 @@ class LightningRepoTest : BaseUnitTest() { assertEquals(probeError, result.exceptionOrNull()) verify(lightningService, never()).stop() verify(lightningService, never()) - .setup(any(), eq(badUrl), anyOrNull(), anyOrNull(), anyOrNull()) + .setup(any(), eq(badUrl), anyOrNull(), anyOrNull()) assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) } @@ -2859,7 +2842,7 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isFailure) assertTrue(result.exceptionOrNull() is NodeConfigNotAppliedError) verify(lightningService, never()) - .setup(any(), eq(newServerUrl), anyOrNull(), anyOrNull(), anyOrNull()) + .setup(any(), eq(newServerUrl), anyOrNull(), anyOrNull()) } // Two-request barrier: a change issued while the background recovery of a previous failed @@ -2874,12 +2857,12 @@ class LightningRepoTest : BaseUnitTest() { val nextUrl = "ssl://next.example.com:50002" whenever(lightningService.node).thenReturn(null) whenever(lightningService.stop()).thenReturn(Unit) - whenever(lightningService.setup(any(), eq(badUrl), anyOrNull(), anyOrNull(), anyOrNull())) + whenever(lightningService.setup(any(), eq(badUrl), anyOrNull(), anyOrNull())) .thenThrow(RuntimeException("start failed")) // Hold the background recovery mid-flight so the second change is issued while it runs. val recoveryStarted = CompletableDeferred() val releaseRecovery = CompletableDeferred() - whenever { lightningService.setup(any(), isNull(), isNull(), anyOrNull(), anyOrNull()) } + whenever { lightningService.setup(any(), isNull(), isNull(), anyOrNull()) } .doSuspendableAnswer { recoveryStarted.complete(Unit) releaseRecovery.await() @@ -2897,7 +2880,7 @@ class LightningRepoTest : BaseUnitTest() { // Success must mean the node was actually rebuilt with the requested server. assertTrue(result.isSuccess) - verify(lightningService).setup(any(), eq(nextUrl), anyOrNull(), anyOrNull(), anyOrNull()) + verify(lightningService).setup(any(), eq(nextUrl), anyOrNull(), anyOrNull()) } // Regression: the caller is a route-scoped ViewModel, so leaving the screen mid-change cancels @@ -2911,7 +2894,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.stop()).thenReturn(Unit) // Hold the rebuild open so the caller can be cancelled while it is in flight. val rebuildStarted = CompletableDeferred() - whenever { lightningService.setup(any(), eq(newUrl), anyOrNull(), anyOrNull(), anyOrNull()) } + whenever { lightningService.setup(any(), eq(newUrl), anyOrNull(), anyOrNull()) } .doSuspendableAnswer { rebuildStarted.complete(Unit) awaitCancellation() @@ -2932,7 +2915,7 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(rethrown) // structured concurrency preserved // The detached recovery rebuilt with the previous config and the node is back up. - verify(lightningService).setup(any(), isNull(), isNull(), anyOrNull(), anyOrNull()) + verify(lightningService).setup(any(), isNull(), isNull(), anyOrNull()) assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) // A cancelled change must never persist the server it failed to apply. verifyBlocking(settingsStore, never()) { update(any()) } @@ -2945,7 +2928,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.node).thenReturn(null) whenever(lightningService.stop()).thenReturn(Unit) val rebuildStarted = CompletableDeferred() - whenever { lightningService.setup(any(), anyOrNull(), eq(newRgsUrl), anyOrNull(), anyOrNull()) } + whenever { lightningService.setup(any(), anyOrNull(), eq(newRgsUrl), anyOrNull()) } .doSuspendableAnswer { rebuildStarted.complete(Unit) awaitCancellation() @@ -2965,7 +2948,7 @@ class LightningRepoTest : BaseUnitTest() { testScheduler.advanceUntilIdle() assertTrue(rethrown) - verify(lightningService).setup(any(), isNull(), isNull(), anyOrNull(), anyOrNull()) + verify(lightningService).setup(any(), isNull(), isNull(), anyOrNull()) assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) verifyBlocking(settingsStore, never()) { update(any()) } } @@ -2982,7 +2965,7 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) val inOrder = inOrder(lightningService) inOrder.verify(lightningService).stop() - inOrder.verify(lightningService).setup(any(), isNull(), eq(customRgsUrl), anyOrNull(), anyOrNull()) + inOrder.verify(lightningService).setup(any(), isNull(), eq(customRgsUrl), anyOrNull()) inOrder.verify(lightningService).start(anyOrNull(), any()) assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) } @@ -3003,7 +2986,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.node).thenReturn(null) whenever(lightningService.stop()).thenReturn(Unit) whenever( - lightningService.setup(any(), isNull(), eq("https://bad.rgs/snapshot"), anyOrNull(), anyOrNull()), + lightningService.setup(any(), isNull(), eq("https://bad.rgs/snapshot"), anyOrNull()), ).thenThrow(RuntimeException("Failed to start node")) val result = sut.restartWithRgsServer("https://bad.rgs/snapshot") @@ -3019,8 +3002,6 @@ class LightningRepoTest : BaseUnitTest() { lightningService = lightningService, settingsStore = settingsStore, coreService = coreService, - lspNotificationsService = lspNotificationsService, - firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, cacheStore = cacheStore, @@ -3030,6 +3011,7 @@ class LightningRepoTest : BaseUnitTest() { vssBackupClientLdk = vssBackupClientLdk, urlValidator = failingValidator, electrumProbeService = electrumProbeService, + sparkRepo = sparkRepo, ) sutWithFailingValidator.setInitNodeLifecycleState() whenever(lightningService.node).thenReturn(mock()) @@ -3178,63 +3160,6 @@ class LightningRepoTest : BaseUnitTest() { assertEquals(serviceError, result.exceptionOrNull()) } - @Test - fun `start should load trusted peers from blocktank info`() = test { - sut.setInitNodeLifecycleState() - whenever(lightningService.node).thenReturn(null) - - val blocktank = mock() - whenever(coreService.blocktank).thenReturn(blocktank) - - val mockNodes = listOf( - ILspNode( - alias = "LSP1", - pubkey = "node1pubkey", - connectionStrings = listOf("node1pubkey@node1.example.com:9735"), - readonly = null, - ), - ILspNode( - alias = "LSP2", - pubkey = "node2pubkey", - connectionStrings = listOf("node2pubkey@node2.example.com:9735"), - readonly = null, - ), - ) - val mockInfo = mock { on { nodes } doReturn mockNodes } - whenever(blocktank.info(refresh = false)).thenReturn(mockInfo) - - val result = sut.start() - - assertTrue(result.isSuccess) - verify(lightningService).setup( - any(), - anyOrNull(), - anyOrNull(), - argThat { peers -> - peers?.size == 2 && - peers.any { it.nodeId == "node1pubkey" && it.address == "node1.example.com:9735" } && - peers.any { it.nodeId == "node2pubkey" && it.address == "node2.example.com:9735" } - }, - anyOrNull(), - ) - } - - @Test - fun `start should pass null trusted peers when blocktank returns null`() = test { - sut.setInitNodeLifecycleState() - whenever(lightningService.node).thenReturn(null) - - val blocktank = mock() - whenever(coreService.blocktank).thenReturn(blocktank) - whenever(blocktank.info(refresh = false)).thenReturn(null) - whenever(blocktank.info(refresh = true)).thenReturn(null) - - val result = sut.start() - - assertTrue(result.isSuccess) - verify(lightningService).setup(any(), anyOrNull(), anyOrNull(), isNull(), anyOrNull()) - } - @Test fun `getBalanceForAddressType should succeed when node is running`() = test { startNodeForTesting() diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt index 076c74f7c4..755bef6919 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt @@ -53,6 +53,7 @@ class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) private val cacheStore = mock() private val settingsStore = mock() private val lightningRepo = mock() + private val sparkRepo = mock() private val publicPaykitRepo = mock() private val coreService = mock() private val clock = mock() @@ -69,6 +70,8 @@ class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) whenever(publicPaykitRepo.payableEndpoints(any())).thenAnswer { it.getArgument>(0) } whenever(coreService.isAddressUsed(any())).thenReturn(false) PublicPaykitRepo.lightningRouteHintsValidator = { true } + whenever { sparkRepo.receivedLightningPaymentHashes() }.thenReturn(emptySet()) + whenever { sparkRepo.attemptedLightningPaymentHashes() }.thenReturn(emptySet()) sut = PrivatePaykitRepo( ioDispatcher = testDispatcher, @@ -78,6 +81,7 @@ class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) settingsStore = settingsStore, addressReservationRepo = mock(), lightningRepo = lightningRepo, + sparkRepo = sparkRepo, walletRepo = mock(), publicPaykitRepo = publicPaykitRepo, paymentRequestRepo = mock(), diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 5e0aa48fea..d4eb3670ee 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -76,6 +76,7 @@ import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -108,6 +109,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { private val settingsStore = mock() private val addressReservationRepo = mock() private val lightningRepo = mock() + private val sparkRepo = idleSparkRepo() private val walletRepo = mock() private val publicPaykitRepo = mock() private val paymentRequestRepo = mock() @@ -189,6 +191,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever { paymentRequestRepo.refreshEligibleTarget(any()) } .thenReturn(Result.success(PaykitPaymentRequestTargetCheck(null, isComplete = true))) whenever(lightningRepo.getPayments()).thenReturn(Result.success(emptyList())) + whenever { sparkRepo.receivedLightningPaymentHashes() }.thenReturn(emptySet()) + whenever { sparkRepo.attemptedLightningPaymentHashes() }.thenReturn(emptySet()) PublicPaykitRepo.lightningRouteHintsValidator = { true } App.currentActivity = CurrentActivity().also { it.onActivityStarted(mock()) } @@ -2224,6 +2228,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { paykitSdkService = paykitSdkService, settingsStore = settingsStore, usdtRepo = mock { on { state }.thenReturn(MutableStateFlow(UsdtWalletState())) }, + sparkRepo = idleSparkRepo(), clock = clock, ) for (failureStage in listOf("withdraw", "disable capability")) { @@ -2933,6 +2938,29 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.closeAndClear() } + @Test + fun `private Lightning invoice paid through Spark is replaced`() = test { + preparePrivateLightningContact() + val refreshedInvoice = "lnbcrt1refreshed" + whenever(lightningRepo.createInvoice(null, "", PRIVATE_BOLT11_EXPIRY_SECONDS)) + .thenReturn(Result.success(refreshedInvoice)) + whenever(coreService.decode(refreshedInvoice)) + .thenReturn(Scanner.Lightning(lightningInvoice(refreshedInvoice, byteArrayOf(8, 8, 8)))) + whenever(sparkRepo.receivedLightningPaymentHashes()).thenReturn(setOf("090909")) + clearInvocations(paykitSdkService) + + sut.refreshKnownSavedContactEndpoints("foreground").getOrThrow() + runCurrent() + + val publication = argumentCaptor>() + verify(paykitSdkService).syncPrivatePaymentListsWithReservations(publication.capture(), eq(false)) + assertEquals( + PublicPaykitRepo.serializePayload(refreshedInvoice), + publication.firstValue.single().reservations.single().payload, + ) + sut.closeAndClear() + } + @Test fun `forced private Lightning refresh includes contacts loaded later`() = test { preparePrivateLightningContact() @@ -4099,6 +4127,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { settingsStore = settingsStore, addressReservationRepo = addressReservationRepo, lightningRepo = lightningRepo, + sparkRepo = sparkRepo, walletRepo = walletRepo, publicPaykitRepo = publicPaykitRepo, paymentRequestRepo = paymentRequestRepo, diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index 7ccbe10aa6..550f5b8d7c 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -33,6 +33,7 @@ import to.bitkit.services.PaykitResolvedPaymentEndpoint import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -616,6 +617,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { lightningRepo = lightningRepo, coreService = coreService, usdtRepo = usdtRepo, + sparkRepo = idleSparkRepo(), paykitSdkService = paykitSdkService, settingsStore = settingsStore, clock = clock, diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitSharingAdmissionTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitSharingAdmissionTest.kt index ab5fe5a8e8..bcb63ea09a 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitSharingAdmissionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitSharingAdmissionTest.kt @@ -29,6 +29,7 @@ import to.bitkit.data.UsdtPreference import to.bitkit.services.CoreService import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -64,6 +65,7 @@ class PublicPaykitSharingAdmissionTest : BaseUnitTest() { settingsStore = settingsStore, clock = Clock.System, usdtRepo = usdtRepo, + sparkRepo = idleSparkRepo(), ) whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow("pubkyself")) whenever(pubkyRepo.isRestoringSession).thenReturn(isRestoringSession) diff --git a/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt b/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt new file mode 100644 index 0000000000..5081ef0f8d --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt @@ -0,0 +1,337 @@ +package to.bitkit.repositories + +import breez_sdk_spark.BitcoinNetwork +import breez_sdk_spark.Conversion +import breez_sdk_spark.ConversionAsset +import breez_sdk_spark.ConversionChain +import breez_sdk_spark.ConversionDetails +import breez_sdk_spark.ConversionProvider +import breez_sdk_spark.ConversionSide +import breez_sdk_spark.ConversionStatus +import breez_sdk_spark.GetInfoResponse +import breez_sdk_spark.ImportUnilateralExitStateResponse +import breez_sdk_spark.InputType +import breez_sdk_spark.Payment +import breez_sdk_spark.PaymentDetails +import breez_sdk_spark.PaymentMethod +import breez_sdk_spark.PaymentStatus +import breez_sdk_spark.PaymentType +import breez_sdk_spark.SdkEvent +import breez_sdk_spark.SparkHtlcDetails +import breez_sdk_spark.SparkHtlcStatus +import breez_sdk_spark.SparkInvoiceDetails +import breez_sdk_spark.TokenBalance +import breez_sdk_spark.TokenMetadata +import breez_sdk_spark.TokenTransactionType +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.OnchainActivity +import kotlinx.coroutines.flow.MutableSharedFlow +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.env.Env +import to.bitkit.ext.create +import to.bitkit.models.SparkExitBackupV1 +import to.bitkit.services.SparkService +import to.bitkit.test.BaseUnitTest +import java.io.File +import java.math.BigInteger +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import com.synonym.bitkitcore.PaymentType as ActivityPaymentType + +class SparkRepoTest : BaseUnitTest() { + private val sparkService = mock() + private val activityRepo = mock() + private val sparkEvents = MutableSharedFlow() + + private fun createSut() = SparkRepo( + bgDispatcher = testDispatcher, + sparkService = sparkService, + activityRepo = { activityRepo }, + settingsStore = mock(), + ) + + @get:Rule + val tempFolder = TemporaryFolder() + + @Before + fun setUp() { + Env.initAppStoragePath(tempFolder.root.absolutePath) + whenever { sparkService.getInfo() }.thenReturn(GetInfoResponse("pubkey", 0uL, emptyMap(), 0uL)) + whenever { sparkService.listUnclaimedDeposits() }.thenReturn(emptyList()) + whenever { sparkService.listPayments() }.thenReturn(emptyList()) + whenever(sparkService.events).thenReturn(sparkEvents) + whenever { activityRepo.getActivity(any(), any()) }.thenReturn(Result.success(null)) + whenever { activityRepo.upsertActivity(any()) }.thenReturn(Result.success(Unit)) + whenever(sparkService.isSupported).thenReturn(true) + whenever(sparkService.networkName).thenReturn("regtest") + } + + @Test + fun `imports a restored exit backup once Spark connects`() = test { + whenever { sparkService.importExitState(any()) }.thenReturn(ImportUnilateralExitStateResponse(2u, 0u, 0u, 0u)) + val sut = createSut() + + sut.restoreExitBackup(SparkExitBackupV1(createdAt = 1, network = "regtest", exitState = "state"), 0) + .getOrThrow() + verify(sparkService, never()).importExitState(any()) + + whenever(sparkService.isConnected).thenReturn(true) + sut.start(0).getOrThrow() + + verify(sparkService).importExitState("state") + assertFalse(File(Env.sparkExitRestorePath(0)).exists()) + } + + @Test + fun `records a send's fee once a sync reports it`() = test { + whenever(sparkService.isConnected).thenReturn(true) + val sut = createSut() + sut.start(0).getOrThrow() + + sparkEvents.emit(SdkEvent.PaymentSucceeded(sentPayment(fees = 0))) + whenever { sparkService.listPayments() }.thenReturn(listOf(sentPayment(fees = 2))) + sparkEvents.emit(SdkEvent.Synced) + + val activities = argumentCaptor() + verify(activityRepo, times(2)).upsertActivity(activities.capture()) + assertEquals(listOf(0uL, 2uL), activities.allValues.map { (it as Activity.Lightning).v1.fee }) + } + + @Test + fun `lists a withdrawal with its fee and marks its arrival in Savings as a transfer`() = test { + val arrival = OnchainActivity.create( + id = "arrival", + txType = ActivityPaymentType.RECEIVED, + txId = "withdrawal", + value = 4_917uL, + fee = 0uL, + address = "bc1q", + timestamp = 1uL, + ) + whenever { activityRepo.getOnchainActivityByTxId(eq("withdrawal"), any()) }.thenReturn(arrival) + whenever { activityRepo.updateActivity(any(), any(), any()) }.thenReturn(Result.success(Unit)) + val withdrawal = sentPayment(fees = 1_470).copy( + method = PaymentMethod.WITHDRAW, + details = PaymentDetails.Withdraw("withdrawal"), + ) + whenever { sparkService.listPayments() }.thenReturn(listOf(withdrawal)) + whenever(sparkService.isConnected).thenReturn(true) + + val sut = createSut() + sut.start(0).getOrThrow() + + val updated = argumentCaptor() + verify(activityRepo).updateActivity(eq("arrival"), updated.capture(), any()) + assertTrue((updated.firstValue as Activity.Onchain).v1.isTransfer) + assertEquals(listOf(SparkWithdrawal("withdrawal", 128uL, 1_470uL)), sut.state.value.withdrawals) + } + + @Test + fun `skips an exit backup from another network`() = test { + val sut = createSut() + + sut.restoreExitBackup(SparkExitBackupV1(createdAt = 1, network = "mainnet", exitState = "state"), 0) + .getOrThrow() + + assertFalse(File(Env.sparkExitRestorePath(0)).exists()) + } + + @Test + fun `lists a broadcast deposit as pending until Spark lists it`() = test { + val sut = createSut() + + sut.onDepositBroadcast("txid", 2_500uL) + + val deposit = sut.state.value.deposits.single() + assertEquals("txid", deposit.txid) + assertEquals(false, deposit.isClaimed) + assertEquals(2_500uL, sut.state.value.pendingDepositSats) + } + + private fun sentPayment(fees: Int) = Payment( + id = "send", + paymentType = PaymentType.SEND, + status = PaymentStatus.COMPLETED, + amount = BigInteger.valueOf(128), + fees = BigInteger.valueOf(fees.toLong()), + timestamp = 1uL, + method = PaymentMethod.LIGHTNING, + details = PaymentDetails.Lightning( + description = null, + invoice = "lnbc1280n1send", + destinationPubkey = "pubkey", + htlcDetails = SparkHtlcDetails("hash", "preimage", 1uL, SparkHtlcStatus.PREIMAGE_SHARED), + lnurlPayInfo = null, + lnurlWithdrawInfo = null, + lnurlReceiveMetadata = null, + conversionInfo = null, + ), + conversionDetails = null, + ) + + @Test + fun `recognizes spark addresses and invoices before parsing`() { + assertTrue(SparkRepo.isSparkInput("sparkrt1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cuy3whr3f0zhs8hu")) + assertTrue(SparkRepo.isSparkInput(" SPARK1abc")) + assertFalse(SparkRepo.isSparkInput("lnbcrt1abc")) + assertFalse(SparkRepo.isSparkInput("0x2d8a9f6c4c37a2f8b7f3a3b1d6c3c8f8d1a2b3c4")) + } + + @Test + fun `scales token units to dollar units and back`() { + assertEquals(1_500_000uL, SparkRepo.toUsdUnits(BigInteger.valueOf(1_500_000), 6)) + assertEquals(1_500_000uL, SparkRepo.toUsdUnits(BigInteger.valueOf(150_000_000), 8)) + assertEquals(BigInteger.valueOf(150_000_000), SparkRepo.fromUsdUnits(1_500_000uL, 8)) + assertEquals(BigInteger.valueOf(1_500_000), SparkRepo.fromUsdUnits(1_500_000uL, 6)) + } + + @Test + fun `loads the dollar token balance and transfers`() = test { + val tokenId = requireNotNull(Env.sparkUsdTokenId) + val metadata = usdMetadata(tokenId) + whenever { sparkService.getInfo() }.thenReturn( + GetInfoResponse( + "pubkey", + 0uL, + mapOf(tokenId to TokenBalance(BigInteger.valueOf(7_000_000), metadata)), + 0uL + ), + ) + whenever { sparkService.tokenMetadata(tokenId) }.thenReturn(metadata) + whenever { sparkService.listPayments() }.thenReturn( + listOf( + tokenPayment("mint", metadata, TokenTransactionType.MINT, 1uL), + tokenPayment("in", metadata, TokenTransactionType.TRANSFER, 2uL), + ), + ) + whenever(sparkService.isConnected).thenReturn(true) + val sut = createSut() + + sut.start(0).getOrThrow() + + val usd = requireNotNull(sut.state.value.usd) + assertEquals("USDT", usd.ticker) + assertEquals(7_000_000uL, usd.balance) + val transfer = usd.transfers.single() + assertEquals("in", transfer.id) + assertEquals(SparkUsdTransfer.Kind.RECEIVED, transfer.kind) + assertTrue(transfer.isIncoming) + assertEquals(2_000_000uL, transfer.amount) + } + + @Test + fun `lists a conversion to dollars with its bitcoin side and fee`() = test { + val tokenId = requireNotNull(Env.sparkUsdTokenId) + val metadata = usdMetadata(tokenId) + whenever { sparkService.tokenMetadata(tokenId) }.thenReturn(metadata) + val conversion = Conversion( + provider = ConversionProvider.AMM, + status = ConversionStatus.COMPLETED, + from = ConversionSide( + chain = ConversionChain.Spark, + asset = ConversionAsset("BTC", "btc", 8u), + amount = BigInteger.valueOf(2_504), + fee = BigInteger.valueOf(801), + ), + to = ConversionSide( + chain = ConversionChain.Spark, + asset = ConversionAsset("USDB", tokenId, 6u), + amount = BigInteger.valueOf(2_000_000), + fee = BigInteger.ZERO, + ), + amountAdjustment = null, + ) + whenever { sparkService.listPayments() }.thenReturn( + listOf( + tokenPayment("convert", metadata, TokenTransactionType.TRANSFER, 2uL) + .copy(conversionDetails = ConversionDetails(ConversionStatus.COMPLETED, listOf(conversion))), + ), + ) + whenever(sparkService.isConnected).thenReturn(true) + val sut = createSut() + + sut.start(0).getOrThrow() + + val transfer = requireNotNull(sut.state.value.usd).transfers.single() + assertEquals(SparkUsdTransfer.Kind.TO_USD, transfer.kind) + assertEquals(2_000_000uL, transfer.amount) + assertEquals(2_504uL, transfer.sats) + assertEquals(801uL, transfer.fee) + } + + @Test + fun `rejects a spark invoice for another token`() = test { + whenever { sparkService.parse("sparkrt1invoice") }.thenReturn( + InputType.SparkInvoice(invoice(tokenId = "btknrt1other", amount = null)), + ) + val sut = createSut() + + val result = sut.parseUsdRecipient("sparkrt1invoice") + + assertIs(result.exceptionOrNull()) + } + + @Test + fun `reads the amount a dollar invoice fixes`() = test { + val tokenId = requireNotNull(Env.sparkUsdTokenId) + whenever { sparkService.tokenMetadata(tokenId) }.thenReturn(usdMetadata(tokenId)) + whenever { sparkService.parse("sparkrt1invoice") }.thenReturn( + InputType.SparkInvoice(invoice(tokenId = tokenId, amount = BigInteger.valueOf(2_500_000))), + ) + val sut = createSut() + + val recipient = sut.parseUsdRecipient("sparkrt1invoice").getOrThrow() + + assertEquals("sparkrt1invoice", recipient.request) + assertEquals(2_500_000uL, recipient.amount) + assertNull(sut.parseUsdRecipient("sparkrt1invoice").exceptionOrNull()) + } + + private fun usdMetadata(tokenId: String) = TokenMetadata( + identifier = tokenId, + issuerPublicKey = "issuer", + name = "Test Tether USD", + ticker = "USDT", + decimals = 6u, + maxSupply = BigInteger.valueOf(Long.MAX_VALUE), + isFreezable = true, + ) + + private fun tokenPayment(id: String, metadata: TokenMetadata, txType: TokenTransactionType, dollars: ULong) = + Payment( + id = id, + paymentType = PaymentType.RECEIVE, + status = PaymentStatus.COMPLETED, + amount = BigInteger.valueOf((dollars * 1_000_000uL).toLong()), + fees = BigInteger.ZERO, + timestamp = dollars, + method = PaymentMethod.TOKEN, + details = PaymentDetails.Token(metadata, "hash-$id", txType, null, null), + conversionDetails = null, + ) + + private fun invoice(tokenId: String, amount: BigInteger?) = SparkInvoiceDetails( + invoice = "sparkrt1invoice", + identityPublicKey = "pubkey", + network = BitcoinNetwork.REGTEST, + amount = amount, + tokenIdentifier = tokenId, + expiryTime = null, + description = null, + senderPublicKey = null, + ) +} diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index e4506b8a6b..45924daf34 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -49,6 +49,7 @@ import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService import to.bitkit.services.OnchainService import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import to.bitkit.usecases.DeriveBalanceStateUseCase import to.bitkit.usecases.WipeIncomplete import to.bitkit.usecases.WipeWalletUseCase @@ -66,6 +67,7 @@ class WalletRepoTest : BaseUnitTest() { private lateinit var sut: WalletRepo private val keychain = mock() + private val sparkRepo = idleSparkRepo() private val coreService = mock() private val settingsStore = mock() private val lightningRepo = mock() @@ -173,6 +175,7 @@ class WalletRepoTest : BaseUnitTest() { transferRepo = transferRepo, activityRepo = activityRepo, hwWalletRepo = hwWalletRepo, + sparkRepo = sparkRepo, ) private fun lightningInvoice(bolt11: String, isExpired: Boolean = false) = LightningInvoice( diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index 2e9736f816..388fc6c08b 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -18,10 +18,15 @@ import org.junit.Test import org.junit.rules.TemporaryFolder import org.lightningdevkit.ldknode.AddressInfo import org.lightningdevkit.ldknode.AddressType +import org.lightningdevkit.ldknode.AnchorChannelsConfig +import org.lightningdevkit.ldknode.BalanceSource +import org.lightningdevkit.ldknode.Config import org.lightningdevkit.ldknode.ConfirmationStatus import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.FeeRate import org.lightningdevkit.ldknode.KeychainKind +import org.lightningdevkit.ldknode.LightningBalance +import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus @@ -33,6 +38,8 @@ import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentDirection import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PaymentStatus +import org.lightningdevkit.ldknode.PeerDetails +import org.lightningdevkit.ldknode.PendingSweepBalance import org.lightningdevkit.ldknode.SpendableUtxo import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -627,6 +634,73 @@ class LightningServiceTest : BaseUnitTest() { assertEquals(listOf(enabled.xpub, authorizing.xpub), configs.map { it.xpub }) } + @Test + fun `node config trusts no peer for zero-conf or force-close broadcasts`() { + val lspNodeIds = listOf("lspNodeId") + val base = Config( + storageDirPath = "", + network = Network.REGTEST, + listeningAddresses = null, + announcementAddresses = null, + nodeAlias = null, + trustedPeers0conf = lspNodeIds, + probingLiquidityLimitMultiplier = 3uL, + anchorChannelsConfig = AnchorChannelsConfig( + trustedPeersNoReserve = lspNodeIds, + perChannelReserveSats = 25_000uL + ), + routeParameters = null, + scoringFeeParams = null, + scoringDecayParams = null, + includeUntrustedPendingInSpendable = false, + addressType = AddressType.NATIVE_SEGWIT, + addressTypesToMonitor = emptyList(), + onchainWalletAccounts = emptyList(), + ) + + val config = nodeConfig(base = base, storageDirPath = "ldk", onchainWalletAccounts = emptyList()) + + assertTrue(config.trustedPeers0conf.isEmpty()) + assertEquals(emptyList(), config.anchorChannelsConfig?.trustedPeersNoReserve) + assertEquals(1uL, config.anchorChannelsConfig?.perChannelReserveSats) + } + + @Test + fun `idle peers are stored peers with no channel and no claimable balance`() { + val peers = listOf( + peer("idle"), + peer("withChannel"), + peer("withClaim"), + peer("notStored", isPersisted = false), + ) + val channels = listOf(createChannelDetails().copy(counterpartyNodeId = "withChannel")) + val claims = listOf( + LightningBalance.ClaimableAwaitingConfirmations( + channelId = "closed", + counterpartyNodeId = "withClaim", + amountSatoshis = 1_000uL, + confirmationHeight = 100u, + source = BalanceSource.HOLDER_FORCE_CLOSED, + ), + ) + + val idle = idlePeerIds(peers, channels, claims, pendingSweeps = emptyList()) + + assertEquals(listOf("idle"), idle) + } + + @Test + fun `no peer is idle while a closed channel awaits its sweep`() { + val sweeps = listOf(PendingSweepBalance.PendingBroadcast(channelId = "closed", amountSatoshis = 1_000uL)) + + val idle = idlePeerIds(listOf(peer("idle")), emptyList(), emptyList(), sweeps) + + assertTrue(idle.isEmpty()) + } + + private fun peer(nodeId: String, isPersisted: Boolean = true) = + PeerDetails(nodeId = nodeId, address = "127.0.0.1:9735", isPersisted = isPersisted, isConnected = true) + @Test fun `reconciliation pre-reveals index 999 for an already tracked authorizing account`() = test { val account = watchOnlyAccount(accountIndex = 5, walletIndex = 0, enabled = true) diff --git a/app/src/test/java/to/bitkit/services/SparkServiceTest.kt b/app/src/test/java/to/bitkit/services/SparkServiceTest.kt new file mode 100644 index 0000000000..edd29ebbfa --- /dev/null +++ b/app/src/test/java/to/bitkit/services/SparkServiceTest.kt @@ -0,0 +1,24 @@ +package to.bitkit.services + +import breez_sdk_spark.SendPaymentOptions +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class SparkServiceTest { + @Test + fun `lightning sends return before completion so callers see the completion event`() { + val options = assertIs(SparkService.bolt11SendOptions(prefersSpark = true)) + + assertNull(options.completionTimeoutSecs) + assertEquals(true, options.preferSpark) + } + + @Test + fun `claim fee estimate covers the claims seen on mainnet`() { + // Mainnet claims so far took 198 and 297 sats; the cap at 1 sat/vB stays above both. + assertEquals(302uL, SparkService.maxDepositClaimFeeSats(fastestFeeRate = 1uL, amountSats = 2_500uL)) + assertEquals(327uL, SparkService.maxDepositClaimFeeSats(fastestFeeRate = 1uL, amountSats = 15_000uL)) + } +} diff --git a/app/src/test/java/to/bitkit/test/IdleSparkRepo.kt b/app/src/test/java/to/bitkit/test/IdleSparkRepo.kt new file mode 100644 index 0000000000..34a3f30ca8 --- /dev/null +++ b/app/src/test/java/to/bitkit/test/IdleSparkRepo.kt @@ -0,0 +1,15 @@ +package to.bitkit.test + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import to.bitkit.repositories.SparkRepo +import to.bitkit.repositories.SparkState + +/** A Spark wallet that is not connected, so every flow takes its Blocktank and channel path. */ +fun idleSparkRepo(): SparkRepo = mock { + on { state } doReturn MutableStateFlow(SparkState()) + on { nodeEvents } doReturn MutableSharedFlow() + on { usdReceived } doReturn MutableSharedFlow() +} diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 203f6163ea..b9be1eac41 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -44,6 +44,7 @@ import to.bitkit.services.BoltzService import to.bitkit.services.MigrationService import to.bitkit.services.PendingChannelMigration import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import to.bitkit.utils.AppError import to.bitkit.viewmodels.RestoreState import to.bitkit.viewmodels.WalletViewModel @@ -67,6 +68,7 @@ class WalletViewModelTest : BaseUnitTest() { private val migrationService = mock() private val connectivityRepo = mock() private val boltzService = mock() + private val sparkRepo = idleSparkRepo() private val lightningState = MutableStateFlow(LightningState()) private val walletState = MutableStateFlow(WalletState()) @@ -104,6 +106,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + sparkRepo = sparkRepo, ) } @@ -536,6 +539,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + sparkRepo = sparkRepo, ) assertEquals(RestoreState.Initial, testSut.restoreState.value) @@ -602,6 +606,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + sparkRepo = sparkRepo, ) testSut.start() @@ -657,6 +662,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + sparkRepo = sparkRepo, ) // Trigger restore to put state in non-idle @@ -696,6 +702,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + sparkRepo = sparkRepo, ) testSut.restoreWallet("mnemonic", null) @@ -786,6 +793,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + sparkRepo = sparkRepo, ) advanceUntilIdle() diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt index 9bacae9389..7bb264c466 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt @@ -49,6 +49,26 @@ class EditInvoiceVMTest : BaseUnitTest() { verify(walletRepo, times(1)).inboundLiquiditySats() } + @Test + fun `onClickContinue should emit none for spending above inbound when Spark can receive`() = test { + whenever(walletRepo.canReceiveWithSpark()).thenReturn(true) + + sut.editInvoiceEffect.test { + sut.onClickContinue( + source = ReceiveLiquiditySource.SPENDING, + amountSats = 10_000u, + isGeoBlocked = true, + ) + + assertEquals( + EditInvoiceScreenEffects.ApplyReceiveLiquidityAction(ReceiveAdditionalLiquidityAction.None), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + verify(walletRepo, times(0)).inboundLiquiditySats() + } + @Test fun `onClickContinue should emit choose amount for spending below CJIT minimum`() = test { whenever(blocktankRepo.maxCjitAmountSats()).thenReturn(Result.success(100_000u)) diff --git a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt index b663a8bdf9..02767d96bd 100644 --- a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt @@ -23,8 +23,11 @@ import to.bitkit.models.TransferType import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState +import to.bitkit.repositories.SparkRepo +import to.bitkit.repositories.SparkState import to.bitkit.repositories.TransferRepo import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -34,6 +37,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { private val transferRepo: TransferRepo = mock() private val settingsStore: SettingsStore = mock() private val hwWalletRepo: HwWalletRepo = mock() + private val sparkRepo: SparkRepo = idleSparkRepo() private lateinit var sut: DeriveBalanceStateUseCase @@ -60,6 +64,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { transferRepo = transferRepo, settingsStore = settingsStore, hwWalletRepo = hwWalletRepo, + sparkRepo = sparkRepo, ) } @@ -97,6 +102,21 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { assertEquals(200_000uL, balanceState.totalSats) } + @Test + fun `should add Spark balance to spending and pending deposits to transfer`() = test { + newBalanceDetails() + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + whenever(sparkRepo.state).thenReturn( + MutableStateFlow(SparkState(isReady = true, balanceSats = 30_000uL, pendingDepositSats = 20_000uL)), + ) + + val balanceState = sut().getOrThrow() + + assertEquals(80_000uL, balanceState.totalLightningSats) + assertEquals(30_000uL, balanceState.maxSendLightningSats) + assertEquals(20_000uL, balanceState.balanceInTransferToSpending) + } + @Test fun `should subtract estimated fee from channelFundableBalance`() = test { val fundableBalance = 75_000uL diff --git a/app/src/test/java/to/bitkit/utils/CryptoTest.kt b/app/src/test/java/to/bitkit/utils/CryptoTest.kt index f1e9ff416a..aae64886f8 100644 --- a/app/src/test/java/to/bitkit/utils/CryptoTest.kt +++ b/app/src/test/java/to/bitkit/utils/CryptoTest.kt @@ -4,12 +4,10 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider import org.junit.After import org.junit.Before import org.junit.Test -import to.bitkit.env.Env.derivationName import to.bitkit.ext.fromBase64 import to.bitkit.ext.fromHex import to.bitkit.ext.toBase64 import to.bitkit.ext.toHex -import to.bitkit.fcm.EncryptedNotification import java.security.Provider import java.security.Security import kotlin.test.assertContentEquals @@ -57,7 +55,7 @@ class CryptoTest { val crypto = Crypto() val keys = crypto.generateKeyPair() assertContentEquals(keys.publicKey, crypto.getPublicKey(keys.privateKey)) - val secret = crypto.generateSharedSecret(keys.privateKey, keys.publicKey.toHex(), derivationName) + val secret = crypto.generateSharedSecret(keys.privateKey, keys.publicKey.toHex(), DERIVATION_NAME) val plaintext = "Provider preservation".toByteArray() val encrypted = crypto.encrypt(plaintext, secret) assertContentEquals(plaintext, crypto.decrypt(encrypted, secret)) @@ -81,14 +79,12 @@ class CryptoTest { val sharedSecret = sut.generateSharedSecret(privateKey, publicKey.toHex()) assertEquals(33, sharedSecret.size) - val sharedSecretHash = sut.generateSharedSecret(privateKey, publicKey.toHex(), derivationName) + val sharedSecretHash = sut.generateSharedSecret(privateKey, publicKey.toHex(), DERIVATION_NAME) assertEquals(32, sharedSecretHash.size) } @Test fun `it should decrypt payload it encrypted`() { - val derivationName = derivationName - // Step 1: Client generates a key pair val clientKeys = sut.generateKeyPair() val clientPublicKey = clientKeys.publicKey @@ -100,14 +96,14 @@ class CryptoTest { val serverPrivateKey = serverKeys.privateKey // Step 3: Server generates shared secret using its private key and client public key - val serverSecret = sut.generateSharedSecret(serverPrivateKey, clientPublicKey.toHex(), derivationName) + val serverSecret = sut.generateSharedSecret(serverPrivateKey, clientPublicKey.toHex(), DERIVATION_NAME) // Step 4: Server encrypts data using the shared secret val dataToEncrypt = "Hello from the server!" val encrypted = sut.encrypt(dataToEncrypt.toByteArray(), serverSecret) assertEquals(12, encrypted.iv.size) assertEquals(16, encrypted.tag.size) - val response = EncryptedNotification( + val response = EncryptedResponse( cipher = encrypted.cipher.toBase64(), iv = encrypted.iv.toHex(), tag = encrypted.tag.toHex(), @@ -115,7 +111,7 @@ class CryptoTest { ) // Step 5: Client generates its shared secret using its private key and server public key - val clientSecret = sut.generateSharedSecret(clientPrivateKey, response.publicKey, derivationName) + val clientSecret = sut.generateSharedSecret(clientPrivateKey, response.publicKey, DERIVATION_NAME) // Step 6: Client decrypts the payload using the shared secret val decrypted = sut.decrypt( @@ -137,7 +133,6 @@ class CryptoTest { fun testBlocktankEncryptedPayload() { val clientPrivateKey = "cc74b1a4fdcd35916c766d3318c5a93b7e33a36ebeff0463128bf284975c2680" val serverPublicKey = "031e9923e689a181a803486b7d8c0d4a5aad360edb70c8bb413a98458d91652213" - val derivationName = "bitkit-notifications" @Suppress("MaxLineLength") val ciphertext = ("l2fInfyw64gO12odo8iipISloQJ45Rc4WjFmpe95brdaAMDq+T/L9ZChcmMCXnR0J6BXd8sSIJe/0bmby8uSZZJuVCzwF76XHfY5oq0Y1/hKzyZTn8nG3dqfiLHnAPy1tZFQfm5ALgjwWnViYJLXoGFpXs7kLMA=").fromBase64() @@ -152,7 +147,7 @@ class CryptoTest { val sharedSecretOnServer = "028ce542975d6d7b2307c92e527d507b03ffb3d897eb2e0830d29f40d5efd80ee3".fromHex() assertEquals(sharedSecretOnServer.toHex(), sharedSecret.toHex()) - val sharedHash = sut.generateSharedSecret(clientPrivateKey.fromHex(), serverPublicKey, derivationName) + val sharedHash = sut.generateSharedSecret(clientPrivateKey.fromHex(), serverPublicKey, DERIVATION_NAME) val sharedHashOnServer = "3a9d552cb16dfae40feae644254c4ca46cab82e570de5662aacc4018e33b609b".fromHex() assertEquals(sharedHashOnServer.toHex(), sharedHash.toHex()) @@ -215,3 +210,12 @@ class CryptoTest { private class OutdatedBcProvider : Provider(BC, 1.0, "Stub for the BC provider that Android registers") } + +private const val DERIVATION_NAME = "bitkit-notifications" + +private data class EncryptedResponse( + val cipher: String, + val iv: String, + val tag: String, + val publicKey: String, +) diff --git a/app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt index a62bcba3eb..114cee5ec4 100644 --- a/app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt +++ b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt @@ -13,14 +13,14 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore -import to.bitkit.models.BalanceState -import to.bitkit.repositories.WalletRepo +import to.bitkit.repositories.PaykitPaymentRequestRepo +import to.bitkit.repositories.PaykitSubscription import to.bitkit.test.BaseUnitTest import to.bitkit.ui.components.TimedSheetType class NotificationsTimedSheetTest : BaseUnitTest() { private lateinit var settingsStore: SettingsStore - private lateinit var walletRepo: WalletRepo + private lateinit var paykitPaymentRequestRepo: PaykitPaymentRequestRepo private lateinit var sut: NotificationsTimedSheet private val defaultSettings = SettingsData() @@ -29,7 +29,7 @@ class NotificationsTimedSheetTest : BaseUnitTest() { @Before fun setUp() = runBlocking { settingsStore = mock() - walletRepo = mock() + paykitPaymentRequestRepo = mock() whenever(settingsStore.data).thenReturn(settingsFlow) whenever(settingsStore.update(any())).then { @@ -38,11 +38,11 @@ class NotificationsTimedSheetTest : BaseUnitTest() { flowOf(settingsFlow.value) } - whenever(walletRepo.balanceState).thenReturn( - MutableStateFlow(BalanceState(totalLightningSats = 1000UL)) + whenever(paykitPaymentRequestRepo.subscriptions).thenReturn( + MutableStateFlow(listOf(mock())) ) - sut = NotificationsTimedSheet(settingsStore, walletRepo) + sut = NotificationsTimedSheet(settingsStore, paykitPaymentRequestRepo) } @Test @@ -65,9 +65,9 @@ class NotificationsTimedSheetTest : BaseUnitTest() { } @Test - fun `should not show when no lightning balance`() = test { - whenever(walletRepo.balanceState).thenReturn( - MutableStateFlow(BalanceState(totalLightningSats = 0UL)) + fun `should not show without subscriptions`() = test { + whenever(paykitPaymentRequestRepo.subscriptions).thenReturn( + MutableStateFlow(emptyList()) ) val result = sut.shouldShow() @@ -89,7 +89,7 @@ class NotificationsTimedSheetTest : BaseUnitTest() { } @Test - fun `should show after one week timeout with lightning balance`() = test { + fun `should show after one week timeout with subscriptions`() = test { val eightDaysAgo = System.currentTimeMillis() - (8L * 24 * 60 * 60 * 1000) settingsFlow.value = defaultSettings.copy( notificationsGranted = false, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index b21e8ee307..c107ea3f41 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -215,6 +215,7 @@ import to.bitkit.services.NodeServiceFgState import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PendingChannelMigration import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import to.bitkit.ui.Routes import to.bitkit.ui.components.Sheet import to.bitkit.ui.components.SubscriptionRoute @@ -925,6 +926,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { paykitPaymentRequestDiagnostics = paykitPaymentRequestDiagnostics, refreshContactPaykitLink = refreshContactPaykitLink, usdtRepo = usdtRepo, + sparkRepo = idleSparkRepo(), paykitUsdtPayments = paykitUsdtPayments, samRockRepo = samRockRepo, appUpdateSheet = mock { on { type } doReturn TimedSheetType.APP_UPDATE }, @@ -2588,6 +2590,36 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals("The payment request is no longer available.", terminalToast.description) } + @Test + fun `manual request whose resolution fails stops loading at once`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") + whenever(context.getString(R.string.slashtags__error_pay_waiting_msg)).thenReturn("Try again") + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( + Result.failure(AppError("Pubky shared state remains locked; retry later")) + ) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + clearInvocations(toastManager) + + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + + assertEquals(Sheet.PaymentRequests, sut.currentSheet.value) + verify(privatePaykitRepo, times(1)).beginPaymentRequest(request) + assertNull(sut.requestedPaymentRequestId.value) + val toast = argumentCaptor() + verify(toastManager).enqueue(toast.capture()) + assertEquals("Try again", toast.firstValue.description) + } + @Test fun `opened request with an invalid target returns to the request sheet`() = test { sut.setIsAuthenticated(true) @@ -3200,7 +3232,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `failed request opened from the full screen does not replace it with the request sheet`() = test { + fun `failed request opened from the full screen stops loading in place`() = test { sut.setIsAuthenticated(true) val request = paymentRequest() whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") @@ -3223,16 +3255,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(sut.currentSheet.value) verify(paykitPaymentRequestRepo).markPresented(request) - verify(privatePaykitRepo, times(15)).beginPaymentRequest(request) - verify(paykitPaymentRequestDiagnostics, times(15)).logPresentationRejection( + verify(privatePaykitRepo, times(1)).beginPaymentRequest(request) + verify(paykitPaymentRequestDiagnostics).logPresentationRejection( request.counterparty, IncomingPaykitPaymentRequestFailureReason.NoSupportedEndpoint, ) val toastCaptor = argumentCaptor() - verify(toastManager, times(2)).enqueue(toastCaptor.capture()) - val (waitingToast, terminalToast) = toastCaptor.allValues - assertNull(waitingToast.testTag) - assertEquals("PaymentRequestUnavailableToast", terminalToast.testTag) + verify(toastManager).enqueue(toastCaptor.capture()) + assertEquals("PaymentRequestWaitingForDetailsToast", toastCaptor.firstValue.testTag) } @Test @@ -12980,6 +13010,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { OnchainSendOutcome.Accepted("ef".repeat(32)) } } + whenever(lightningRepo.isTransferBlocked()).thenReturn(false) val invalidOrders = listOf( order.copy(state2 = com.synonym.bitkitcore.BtOrderState2.PAID), order.copy(orderExpiresAt = (Clock.System.now() - 1.seconds).toString()), @@ -12996,6 +13027,40 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(1, dispatched) } + @Test + fun `recovery of a Blocktank order broadcasts nothing while transfers are unavailable`() = test { + val order = to.bitkit.ext.mockOrder().copy( + orderExpiresAt = (Clock.System.now() + 60.seconds).toString(), + ) + val original = OnchainSendAttempt( + walletId = WalletScope.default, + attemptId = "original-order-attempt", + transferContext = to.bitkit.repositories.OnchainTransferContext( + 1_200uL, + 100_000uL, + order.clientBalanceSat, + order.feeSat, + ), + requestId = null, + orderId = order.id, + address = requireNotNull(order.payment?.onchain?.address), + amountSats = order.feeSat, + isMaxAmount = false, + feeRateSatsPerVByte = 1uL, + isTransfer = true, + channelId = null, + tags = emptyList(), + evidence = OnchainSendEvidence.Unknown, + ) + whenever(lightningRepo.isTransferBlocked()).thenReturn(true) + whenever(blocktankRepo.fetchOrders(listOf(order.id))).thenReturn(Result.success(listOf(order))) + + assertTrue(sut.retryOriginalOnchainSend(original, 2uL).isFailure) + + verify(lightningRepo, never()).retryOriginalOnchainSend(any(), any(), any(), anyOrNull(), any(), any()) + assertEquals(Sheet.TransferUnavailable, sut.currentSheet.value) + } + @Test fun `private lightning contact payment consumes private list before send`() = test { val bolt11 = "lnbcrt1privatecontact" @@ -13031,6 +13096,36 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).consumePrivatePaymentList(contactKey, privateContext) } + @Test + fun `spark send that settled before the watch started completes without pending`() = test { + val bolt11 = "lnbcrt1sparktospark" + val paymentHash = "spark_hash" + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)).thenReturn(Result.success(paymentHash)) + whenever(lightningRepo.settledSparkSendEvent(paymentHash)).thenReturn( + Event.PaymentSuccessful( + paymentId = paymentHash, + paymentHash = paymentHash, + paymentPreimage = "preimage", + feePaidMsat = 0uL, + ), + ) + setSendState( + SendUiState( + address = bolt11, + amount = 1000u, + payMethod = SendMethod.LIGHTNING, + decodedInvoice = lightningInvoice(bolt11, amountSats = 1000u), + ), + ) + + sut.setSendEvent(SendEvent.PayConfirmed) + advanceTimeBy(LightningRepo.SEND_LN_TIMEOUT.inWholeMilliseconds + 1_000) + runCurrent() + + verify(pendingPaymentRepo, never()).track(paymentHash) + } + @Test fun `private lightning pending payment consumes private list`() = test { val bolt11 = "lnbcrt1pending" diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 991be6cf8b..90190df41b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -99,6 +99,7 @@ import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.BoltzService import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import to.bitkit.ui.screens.transfer.previewBtOrder import to.bitkit.ui.screens.transfer.previewSpendingState import to.bitkit.ui.shared.toast.ToastEventBus @@ -120,6 +121,7 @@ class TransferViewModelTest : BaseUnitTest() { private lateinit var sut: TransferViewModel private val context = mock() + private val sparkRepo = idleSparkRepo() private val lightningRepo = mock() private val blocktankRepo = mock() private val hwWalletRepo = mock() @@ -168,6 +170,7 @@ class TransferViewModelTest : BaseUnitTest() { } .thenReturn(Result.success("transfer-id")) whenever { hwWalletRepo.needsPassphrase(any()) }.thenReturn(false) + whenever { lightningRepo.isTransferBlocked() }.thenReturn(false) val nodeStatus = mock() whenever(nodeStatus.isRunning).thenReturn(true) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(nodeStatus = nodeStatus))) @@ -203,11 +206,21 @@ class TransferViewModelTest : BaseUnitTest() { cacheStore = cacheStore, transferRepo = transferRepo, clock = clock, + sparkRepo = sparkRepo, boltzService = boltzService, currencyRepo = currencyRepo, ) } + @Test + fun `transfer to savings with only spark doesn't wait for a lightning peer`() = test { + sut.onTransferToSavingsConfirm(channels = emptyList(), mode = SavingsTransferMode.CLOSE) + + assertFalse(sut.hasChannelsToClose()) + assertTrue(sut.closeSelectedChannels().isEmpty()) + verify(lightningRepo, never()).awaitPeerConnected(any()) + } + @Test fun `updateLimits caps spending max at LSP max client balance when on-chain balance exceeds it`() = test { stubSpendableBalances(ON_CHAIN_BALANCE) @@ -1200,6 +1213,48 @@ class TransferViewModelTest : BaseUnitTest() { verify(blocktankRepo, times(1)).createOrder(eq(order.clientBalanceSat), eq(order.lspBalanceSat), any()) } + @Test + fun `onTransferToSpendingConfirm pays no held order when transfers are unavailable`() = test { + val order = spendingOrder(feeSat = 99_000uL) + stubSpendableBalances(spendable = 200_000u) + whenever { + lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) + }.thenReturn(Result.success(listOf(stubUtxo(200_000u)))) + whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(500uL)) + stubSendOnChain() + val effects = mutableListOf() + backgroundScope.launch { sut.transferEffects.collect { effects += it } } + quoteOrder(order) + prepareConfirm() + whenever(lightningRepo.isTransferBlocked()).thenReturn(true) + + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + + assertTrue(TransferEffect.OnTransferUnavailable in effects) + assertEquals(false, sut.spendingUiState.value.isConfirmPaying) + verify(lightningRepo, never()).sendOnChain( + address = any(), + sats = any(), + speed = any(), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = any(), + channelId = anyOrNull(), + isMaxAmount = any(), + tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), + payerIdentity = anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ) + } + @Test fun `onTransferToSpendingConfirm does not drain when normal fee leaves non-dust change`() = test { val order = spendingOrder(feeSat = 35_341uL) @@ -2821,6 +2876,7 @@ class TransferViewModelTest : BaseUnitTest() { cacheStore = cacheStore, transferRepo = transferRepo, clock = clock, + sparkRepo = sparkRepo, boltzService = boltzService, currencyRepo = currencyRepo, ) diff --git a/changelog.d/next/1696.changed.md b/changelog.d/next/1696.changed.md new file mode 100644 index 0000000000..3020a38b33 --- /dev/null +++ b/changelog.d/next/1696.changed.md @@ -0,0 +1 @@ +Transfers to spending now point to Manual Setup, since Bitkit no longer opens Lightning connections automatically, and a forced close of a Blocktank connection no longer leaves funds stuck. diff --git a/changelog.d/next/1697.added.md b/changelog.d/next/1697.added.md new file mode 100644 index 0000000000..e1e6a1d79d --- /dev/null +++ b/changelog.d/next/1697.added.md @@ -0,0 +1 @@ +Spending keeps Lightning without channels: transfers from Savings now fund a Spark wallet that sends and receives Lightning payments. diff --git a/changelog.d/next/1710.added.md b/changelog.d/next/1710.added.md new file mode 100644 index 0000000000..eb3fc213a3 --- /dev/null +++ b/changelog.d/next/1710.added.md @@ -0,0 +1 @@ +Added a USDB dollar balance on Spark that you can convert with bitcoin, move to USDT, and send to other Bitkit wallets for free. diff --git a/docs/screens-map.md b/docs/screens-map.md index bfde34c946..fe1bc908c8 100644 --- a/docs/screens-map.md +++ b/docs/screens-map.md @@ -226,6 +226,7 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | Android | Figma | | - | - | +| SparkUsdWalletScreen.kt | `todo` | | UsdtDepositReceiveScreen.kt | Receive USDT › HW QR / Recipient / Specify Amount (populated) / Fee To Add Liquidity | | UsdtDepositsScreen.kt | Activity › Transaction Cross Network USDT / Transaction Receiving USDT Tech details | | UsdtWalletScreen.kt | Savings, Spending, Hardware, USDT Balances › Wallet USDT (funds & activity) / Wallet USDT (empty) | @@ -322,6 +323,7 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | ChannelDetailScreen.kt | Settings > Advanced › Connection details | | CloseConnectionScreen.kt | Settings > Advanced › Close connection | | LightningConnectionsScreen.kt | Settings > Advanced › Lightning Connections overview | +| SparkDetailScreen.kt | `todo` | ## ui/settings/pin diff --git a/docs/timed-sheets.md b/docs/timed-sheets.md index 9a7995eb27..124f8d9c60 100644 --- a/docs/timed-sheets.md +++ b/docs/timed-sheets.md @@ -7,7 +7,7 @@ The manager checks sheets two seconds after the home screen resumes and cancels 1. App update 2. USDT 3. Backup -4. Background payments +4. Notifications 5. QuickPay 6. High balance @@ -55,15 +55,15 @@ Backup uses a snooze model instead of a permanent seen flag: After the one-day ask interval passes, the Backup timed sheet can appear again if the backup is still unverified and the wallet still has a balance. -## Background Payments +## Notifications -The Background Payments timed sheet is eligible when notification permission has not been granted, the wallet has a Lightning balance, and `notificationsIgnoredMillis` is older than one week. +The Notifications timed sheet is eligible when notification permission has not been granted, the wallet has at least one Paykit subscription, and `notificationsIgnoredMillis` is older than one week. -- `Later` marks the background-payments intro as seen and dismisses the sheet. Dismissal records `notificationsIgnoredMillis`. +- `Later` marks the notifications intro as seen and dismisses the sheet. Dismissal records `notificationsIgnoredMillis`. - `Enable` marks the intro as seen, dismisses the sheet, records `notificationsIgnoredMillis`, and requests notification permission. - A generic timed sheet dismissal records `notificationsIgnoredMillis` without marking the intro as seen. -After the one-week ask interval passes, the timed sheet can appear again if notification permission is still not granted and the wallet still has a Lightning balance. The full-screen settings intro is a separate route with only an enable action. +After the one-week ask interval passes, the timed sheet can appear again if notification permission is still not granted and the wallet still has a Paykit subscription. The full-screen settings intro is a separate route with only an enable action. ## High Balance diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1b857067ed..3af9a3faa9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.8.0-rc10" } +breez-sdk-spark = { module = "breez_sdk_spark:bindings-android", version = "0.27.2" } paykit = { module = "com.synonym:paykit-android", version = "0.1.1-rc1" } bouncycastle-pkix-jdk = { module = "org.bouncycastle:bcpkix-jdk18on", version = "1.83" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } diff --git a/journeys/spark-usd-bitkit-to-bitkit.xml b/journeys/spark-usd-bitkit-to-bitkit.xml new file mode 100644 index 0000000000..7a9d09d891 --- /dev/null +++ b/journeys/spark-usd-bitkit-to-bitkit.xml @@ -0,0 +1,12 @@ + + Use two onboarded dev builds (regtest), A and B, with Spark connected. Fund A with the Spark test dollar token first (see the PR's manual test: the token's issuer wallet is not part of the journey environment). The dollar balance on Spark is separate from the Arbitrum USDT wallet. + + On Home of A, verify a tile (testTag "SparkUsdWallet") shows the token ticker, "Spark" and its dollar balance, next to Savings and Spending, and that the total balance includes it. + Open the tile. Verify the title reads "USDT · Spark", the balance header (testTag "SparkUsdBalance") matches the tile, and the funding payment is listed as Received. + On B, open the tile and tap Receive. Verify a QR (testTag "SparkUsdReceiveQr") holding a Spark invoice (sparkrt1…) and "Network: Spark" below it. + Hand B's invoice to A as a URI (lightning:<invoice>). Verify A opens the dollar send flow on the amount step with its available balance, without a recipient screen. + Enter 10, tap Continue (testTag "SparkUsdReview"), and swipe to pay. Verify the sheet shows Sent USDT, then close it (testTag "SparkUsdDone"). + Verify A's balance dropped by exactly 10.00 with a Sent row, and B's balance rose by 10.00 with a Received row, with no fee on either side. + On A, start a send and enter a Lightning invoice or a Spark invoice for sats as the recipient. Verify the recipient is rejected with "Enter a valid address for the selected network." and nothing is sent. + + diff --git a/journeys/transfer/transfer-unavailable-sheet.xml b/journeys/transfer/transfer-unavailable-sheet.xml new file mode 100644 index 0000000000..1e9b604ed9 --- /dev/null +++ b/journeys/transfer/transfer-unavailable-sheet.xml @@ -0,0 +1,27 @@ + + + Verifies that Bitkit points users to Manual Setup now that Blocktank no longer sells Lightning channels. + Blocktank's geocheck answers 403, so the app is geo-blocked, and the Transfer To Spending buttons on the + wallet screens open the "Transfer Unavailable" sheet (testTag "TransferUnavailable") instead of the transfer flow. The + sheet shows "MANUAL SETUP REQUIRED" (testTag "TransferUnavailableTitle") and the buttons "Learn More" + (testTag "TransferUnavailableLearnMore"), which opens https://blog.bitkit.to/ in the browser, and "Continue" + (testTag "TransferUnavailableContinue"), which opens the existing Manual Setup screen. Receive's Lightning + path shows the same sheet content. + + Precondition: dev build with geoblocking on (the default; `GEO=false` turns it off), onboarded wallet with + a positive on-chain Savings balance and no channels. Start on the wallet home screen. + + + Tap the Savings balance card (testTag "ActivitySavings") and verify "Transfer To Spending" (testTag "TransferToSpending") is shown + Tap "Transfer To Spending" and verify the sheet titled "Transfer Unavailable" shows "MANUAL SETUP REQUIRED" and "Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider." + Tap "Learn More" and verify the browser opens blog.bitkit.to, then return to Bitkit + Tap "Continue" and verify the sheet closes and the "MANUAL SETUP" screen shows the node id (testTag "NodeIdInput"), host (testTag "HostInput") and port (testTag "PortInput") fields + Go back to the wallet home, tap the Spending balance card (testTag "ActivitySpending"), tap the transfer button (testTag "TransferFromSavings") and verify the same sheet opens; close it + Open Settings › Advanced › Lightning Connections, tap add connection (testTag "add_connection_button") and verify the screen text reads "Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider." + Verify "Manual Setup" (testTag "FundManual") is the first option and enabled, and "Transfer from Savings" (testTag "FundTransfer"), "Transfer from HW wallet" (testTag "FundHardware") and "Use Other Wallet" (testTag "FundReceive") below it are disabled; tap "Transfer from Savings" and verify nothing opens + Tap "Learn More" (testTag "FundLearnMore") at the bottom and verify the browser opens blog.bitkit.to, then return to Bitkit and go back to the wallet home + Tap "Receive" (testTag "Receive"), select the Spending tab (testTag "Tab-spending") and tap "Receive Lightning funds" + Verify the screen titled "Transfer Unavailable" shows "MANUAL SETUP REQUIRED" and "Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider.", with a back button + Tap "Continue" and verify the sheet closes and the "MANUAL SETUP" screen opens + + diff --git a/scripts/spark-regtest-esplora.py b/scripts/spark-regtest-esplora.py new file mode 100755 index 0000000000..d5a3863116 --- /dev/null +++ b/scripts/spark-regtest-esplora.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Esplora endpoint on Spark's regtest chain for a dev build's ldk-node. + +Spark's regtest is Lightspark's own chain. Its only public chain API is a mempool.space instance +(the Breez SDK's regtest default), which answers Esplora's address, transaction and block calls +but has no `/fee-estimates`, which ldk-node reads at start, nor `/block//status`, which its +Lightning sync reads. This proxy serves `/fee-estimates` from the instance's `/v1/fees/recommended`, +builds a block's status from `/block/` and `/block-height/`, and forwards everything +else, so a dev build built with `ESPLORA_URL=http://:` runs on Spark's chain. + +Usage: + python3 scripts/spark-regtest-esplora.py [--port 3999] [--bind 0.0.0.0] [--max-seconds 10800] + # emulator on the same host: ESPLORA_URL=http://10.0.2.2:3999 +""" +import argparse +import json +import threading +import time +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +UPSTREAM = "https://regtest-mempool.us-west-2.sparkinfra.net/api" + + +def log(*parts): + print(time.strftime("%H:%M:%S"), *parts, flush=True) + + +def fetch(path): + with urllib.request.urlopen(f"{UPSTREAM}{path}", timeout=15) as response: + return response.read().decode() + + +def block_status(block_hash): + height = json.loads(fetch(f"/block/{block_hash}"))["height"] + in_best_chain = fetch(f"/block-height/{height}").strip() == block_hash + try: + next_best = fetch(f"/block-height/{height + 1}").strip() if in_best_chain else None + except urllib.error.HTTPError: + next_best = None + return {"in_best_chain": in_best_chain, "height": height, "next_best": next_best} + + +def fee_estimates(): + with urllib.request.urlopen(f"{UPSTREAM}/v1/fees/recommended", timeout=15) as response: + fees = json.load(response) + targets = {1: "fastestFee", 2: "fastestFee", 3: "halfHourFee", 6: "hourFee", 12: "economyFee", 144: "minimumFee"} + return {str(blocks): float(fees.get(key, 1)) for blocks, key in targets.items()} + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + self.forward() + + def do_POST(self): + self.forward() + + def forward(self): + if self.path.rstrip("/") == "/fee-estimates": + return self.reply(200, json.dumps(fee_estimates()).encode(), "application/json") + parts = self.path.strip("/").split("/") + if len(parts) == 3 and parts[0] == "block" and parts[2] == "status": + return self.reply(200, json.dumps(block_status(parts[1])).encode(), "application/json") + body = self.rfile.read(int(self.headers.get("Content-Length") or 0)) if self.command == "POST" else None + request = urllib.request.Request(f"{UPSTREAM}{self.path}", data=body, method=self.command) + if self.headers.get("Content-Type"): + request.add_header("Content-Type", self.headers["Content-Type"]) + try: + with urllib.request.urlopen(request, timeout=30) as response: + self.reply(response.status, response.read(), response.headers.get("Content-Type")) + except urllib.error.HTTPError as error: + self.reply(error.code, error.read(), error.headers.get("Content-Type")) + + def reply(self, status, payload, content_type): + self.send_response(status) + if content_type: + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, fmt, *args): + log(self.command, self.path, *args[1:2]) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--port", type=int, default=3999) + parser.add_argument("--bind", default="127.0.0.1") + parser.add_argument("--max-seconds", type=int, default=3 * 3600) + args = parser.parse_args() + server = ThreadingHTTPServer((args.bind, args.port), Handler) + threading.Timer(args.max_seconds, server.shutdown).start() + log(f"serving Spark regtest Esplora on {args.bind}:{args.port} for {args.max_seconds}s") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/scripts/spark-regtest-fund.py b/scripts/spark-regtest-fund.py new file mode 100755 index 0000000000..20a3f7de16 --- /dev/null +++ b/scripts/spark-regtest-fund.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Fund an address on Spark's regtest chain from Lightspark's regtest faucet. + +Use it on a dev build running on Spark's chain (see `spark-regtest-esplora.py`): fund the Savings +address to test Savings to Spending and the deposit claim, or Spark's deposit address on the Spark +screen to fund Spending directly. Lightspark's API faucet takes HTTP basic auth: set +FAUCET_USERNAME and FAUCET_PASSWORD (issued by Lightspark). The web faucet at +https://app.lightspark.com/regtest-faucet works without them, by hand. + +Usage: + FAUCET_USERNAME=… FAUCET_PASSWORD=… python3 scripts/spark-regtest-fund.py [sats] +""" +import base64 +import json +import os +import sys +import urllib.request + +FAUCET_URL = os.environ.get("FAUCET_URL", "https://api.lightspark.com/graphql/spark/rc") +QUERY = ( + "mutation RequestRegtestFunds($address: String!, $amount_sats: Long!) " + "{ request_regtest_funds(input: {address: $address, amount_sats: $amount_sats}) { transaction_hash } }" +) + + +def main(): + if len(sys.argv) < 2: + sys.exit(__doc__) + address = sys.argv[1] + amount_sats = int(sys.argv[2]) if len(sys.argv) > 2 else 100_000 + username, password = os.environ.get("FAUCET_USERNAME"), os.environ.get("FAUCET_PASSWORD") + if not username or not password: + sys.exit("Set FAUCET_USERNAME and FAUCET_PASSWORD, or use https://app.lightspark.com/regtest-faucet") + body = json.dumps({ + "operationName": "RequestRegtestFunds", + "variables": {"address": address, "amount_sats": amount_sats}, + "query": QUERY, + }).encode() + request = urllib.request.Request(FAUCET_URL, data=body, method="POST") + request.add_header("Content-Type", "application/json") + request.add_header("Authorization", "Basic " + base64.b64encode(f"{username}:{password}".encode()).decode()) + with urllib.request.urlopen(request, timeout=60) as response: + result = json.load(response) + if result.get("errors"): + sys.exit("Faucet returned errors: " + ", ".join(e["message"] for e in result["errors"])) + print(result["data"]["request_regtest_funds"]["transaction_hash"]) + + +if __name__ == "__main__": + main() diff --git a/settings.gradle.kts b/settings.gradle.kts index 00170049a1..c9570f5271 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -87,6 +87,10 @@ dependencyResolutionManagement { password = pass } } + maven { + url = uri("https://mvn.breez.technology/releases") + content { includeGroup("breez_sdk_spark") } + } maven { url = uri("https://maven.pkg.github.com/pubky/paykit-rs") credentials {