From 8bb27fd31822a49c939d0f63dc4c91a817e21399 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 22:51:26 +0100 Subject: [PATCH 1/7] feat: pay contacts through pubky swaps --- app/build.gradle.kts | 4 + app/src/main/java/to/bitkit/env/Env.kt | 11 ++ .../repositories/PubkySwapsRouteRepo.kt | 109 ++++++++++++++ .../screens/wallets/send/SendConfirmScreen.kt | 17 +++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 137 +++++++++++++++++- app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-b+es+419/strings.xml | 1 + app/src/main/res/values-ca/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-el/strings.xml | 1 + app/src/main/res/values-es-rES/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/strings.xml | 2 + .../viewmodels/AppViewModelSendFlowTest.kt | 3 + journeys/paykit-swap-route.xml | 29 ++++ 23 files changed, 324 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/to/bitkit/repositories/PubkySwapsRouteRepo.kt create mode 100644 journeys/paykit-swap-route.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6198983ef9..b3f1b5128c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -81,6 +81,8 @@ val e2eHomegateUrlEnv = providers.environmentVariable("E2E_HOMEGATE_URL") .orElse(e2eLocalHostEnv.map { "http://$it:6288" }) val e2eHomeserverPubkyEnv = providers.environmentVariable("E2E_HOMESERVER_PUBKY").orElse("") val e2eHomegateOverrideEnv = providers.environmentVariable("E2E_HOMEGATE_URL").orElse("") +val pubkySwapsRoutesEnv = envFlag("PUBKY_SWAPS_ROUTES", default = false) +val pubkySwapsProvidersEnv = providers.environmentVariable("PUBKY_SWAPS_PROVIDERS").orElse("") val paykitUiDisabledEnv = envFlag("PAYKIT_UI_DISABLED", default = false) val trezorBridgeEnv = localProp("TREZOR_BRIDGE").map { it.toBoolean().toString() }.orElse("false") val trezorBridgeUrlEnv = localProp("TREZOR_BRIDGE_URL").orElse("http://10.0.2.2:21325") @@ -362,6 +364,8 @@ androidComponents { buildConfigFields.put("E2E_HOMEGATE_URL", e2eHomegateUrlEnv.stringField()) buildConfigFields.put("E2E_HOMESERVER_PUBKY", e2eHomeserverPubkyEnv.stringField()) buildConfigFields.put("E2E_HOMEGATE_OVERRIDE", e2eHomegateOverrideEnv.stringField()) + buildConfigFields.put("PUBKY_SWAPS_ROUTES", pubkySwapsRoutesEnv.booleanField()) + buildConfigFields.put("PUBKY_SWAPS_PROVIDERS", pubkySwapsProvidersEnv.stringField()) for (key in listOf("E2E_VSS_SERVER_URL", "E2E_LNURL_AUTH_SERVER_URL", "E2E_BLOCKTANK_API_URL")) { val value = if (variant.buildType == "debug") localProp(key).orElse("") else provider { "" } buildConfigFields.put(key, value.stringField()) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 83a1ed564f..8555ab0d36 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -54,6 +54,17 @@ internal object Env { */ val isSwapSupported get() = network == Network.BITCOIN + /** + * Whether a Paykit payment may route through a Pubky Swaps provider when no balance reaches the contact's + * endpoints. Regtest debug builds with `PUBKY_SWAPS_ROUTES=true` only. + */ + val isPubkySwapsRouteEnabled: Boolean + get() = isDebug && network == Network.REGTEST && BuildConfig.PUBKY_SWAPS_ROUTES + + /** Swap providers to ask besides the user's contacts, from `PUBKY_SWAPS_PROVIDERS` (comma-separated pubkys). */ + val pubkySwapsProviders: List + get() = BuildConfig.PUBKY_SWAPS_PROVIDERS.split(',').map { it.trim() }.filter { it.isNotEmpty() } + const val fxRateRefreshInterval = 2 * 60 * 1000L // 2 minutes in millis const val fxRateStaleThreshold = 10 * 60 * 1000L // 10 minutes in millis const val lspOrdersRefreshInterval = 2 * 60 * 1000L // 2 minutes in millis diff --git a/app/src/main/java/to/bitkit/repositories/PubkySwapsRouteRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkySwapsRouteRepo.kt new file mode 100644 index 0000000000..35532eb050 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PubkySwapsRouteRepo.kt @@ -0,0 +1,109 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.PaykitEndpoint +import com.synonym.bitkitcore.PaykitRoute +import com.synonym.bitkitcore.PaykitRouteRequest +import com.synonym.bitkitcore.PubkySwapRoutePayment +import com.synonym.bitkitcore.PubkySwapRouteState +import com.synonym.bitkitcore.pubkySwapsAdvance +import com.synonym.bitkitcore.pubkySwapsCommitRoute +import com.synonym.bitkitcore.pubkySwapsRoutePaykitPayment +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import to.bitkit.data.keychain.Keychain +import to.bitkit.di.IoDispatcher +import to.bitkit.env.Env +import to.bitkit.ext.nowMillis +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.toCoreNetwork +import to.bitkit.utils.AppError +import to.bitkit.utils.Logger +import to.bitkit.utils.ServiceError +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * Routes a Paykit payment through a Pubky Swaps provider when no balance the wallet holds reaches an endpoint the + * contact published. bitkit-core picks the provider and checks its quote, contract and hold invoice; the wallet pays + * the hold invoice from spending and bitkit-core claims the provider's lockup straight to the contact's address. + */ +@Singleton +class PubkySwapsRouteRepo @Inject constructor( + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val keychain: Keychain, + private val pubkyRepo: PubkyRepo, +) { + companion object { + private const val TAG = "PubkySwapsRouteRepo" + private const val CLAIM_FEE_RATE_SAT_VB = 2uL + private val POLL_INTERVAL = 2.seconds + private val CLAIM_TIMEOUT = 10.minutes + } + + val isEnabled: Boolean get() = Env.isPubkySwapsRouteEnabled + + /** The contact behind a provider's pubky, so the review shows who routes the payment. */ + fun providerContact(pubky: String): PubkyProfile? = + pubkyRepo.contacts.value.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, pubky) } + + suspend fun route( + amountSats: ULong, + endpoints: List, + lightningSpendableSats: ULong, + onchainSpendableSats: ULong, + ): Result = withSecrets { mnemonic, passphrase -> + // Providers come from the user's own graph: every contact is asked for its swap offer. + val candidates = pubkyRepo.contacts.value.map { it.publicKey } + Env.pubkySwapsProviders + Logger.debug("Routing Paykit payment via '${candidates.map(PubkyPublicKeyFormat::redacted)}'", context = TAG) + pubkySwapsRoutePaykitPayment( + request = PaykitRouteRequest( + amountSat = amountSats, + contactEndpoints = endpoints, + lightningSpendableSat = lightningSpendableSats, + onchainSpendableSat = onchainSpendableSats, + providers = candidates, + feeRateSatVb = CLAIM_FEE_RATE_SAT_VB, + ), + network = Env.network.toCoreNetwork(), + mnemonic = mnemonic, + bip39Passphrase = passphrase, + ) + } + + suspend fun commit(quoteId: String): Result = withSecrets { mnemonic, passphrase -> + pubkySwapsCommitRoute(quoteId, Env.electrumServerUrl, mnemonic, passphrase) + } + + /** Advance the swap until bitkit-core claims the lockup to the contact, returning the claim txid. */ + suspend fun awaitClaim(swapId: String, timeout: Duration = CLAIM_TIMEOUT): Result { + val deadline = nowMillis() + timeout.inWholeMilliseconds + while (true) { + val status = withSecrets { mnemonic, passphrase -> pubkySwapsAdvance(swapId, mnemonic, passphrase) } + .getOrElse { return Result.failure(it) } + when (val state = status.state) { + is PubkySwapRouteState.Claimed -> return Result.success(state.txid) + is PubkySwapRouteState.Failed -> return Result.failure(AppError(state.reason)) + else -> Logger.debug("Pubky Swaps route '$swapId': $state", context = TAG) + } + if (nowMillis() > deadline) { + return Result.failure(AppError("Swap '$swapId' not claimed yet: '${status.state}'")) + } + delay(POLL_INTERVAL) + } + } + + private suspend fun withSecrets(block: suspend (String, String?) -> T): Result = withContext(ioDispatcher) { + runSuspendCatching { + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw ServiceError.MnemonicNotFound() + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + block(mnemonic, passphrase) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt index be4d81f02d..be3776bf1c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt @@ -58,6 +58,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.ext.commentAllowed +import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.formatInvoiceExpiryRelative import to.bitkit.models.FeeRate import to.bitkit.models.PaykitAsset @@ -107,6 +108,9 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds private val EXPIRY_REFRESH_INTERVAL = 60.seconds + +/** Characters of a swap provider's pubky shown on the confirm screen. */ +private const val PROVIDER_KEY_LENGTH = 12 const val SEND_CONFIRM_RESET_RESULT_KEY = "SEND_CONFIRM_RESET_RESULT_KEY" @Composable @@ -873,6 +877,19 @@ private fun LightningDetails( } } + uiState.paykitSwapRoute?.let { route -> + val quote = route.quote ?: return@let + val provider = route.providerName?.takeIf { it.isNotBlank() } + ?: quote.providerPubky.ellipsisMiddle(PROVIDER_KEY_LENGTH) + SendCell(caption = stringResource(R.string.wallet__send_swap_route)) { + BodySSB( + text = stringResource(R.string.wallet__send_swap_route_provider, provider), + maxLines = 1, + modifier = Modifier.testTag("ReviewSwapRoute") + ) + } + } + if (!isLnurlPay && !description.isNullOrEmpty() && description.trim() != uiState.confirmationRequestNote) { SendCell(caption = stringResource(R.string.wallet__note)) { Row(modifier = Modifier.horizontalScroll(rememberScrollState())) { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index ebbc027624..1584c31d62 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -27,7 +27,10 @@ import com.synonym.bitkitcore.LnurlChannelData import com.synonym.bitkitcore.LnurlPayData import com.synonym.bitkitcore.LnurlWithdrawData import com.synonym.bitkitcore.OnChainInvoice +import com.synonym.bitkitcore.PaykitEndpoint +import com.synonym.bitkitcore.PaykitRoute import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.PubkySwapRouteQuote import com.synonym.bitkitcore.Scanner import com.synonym.bitkitcore.SortDirection import com.synonym.bitkitcore.UsdtQuote @@ -214,6 +217,7 @@ import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyIdentityReadiness import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PubkySignIn +import to.bitkit.repositories.PubkySwapsRouteRepo import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.QuickPayPaymentFailedError @@ -316,6 +320,7 @@ class AppViewModel @Inject constructor( private val paykitPaymentRequestDiagnostics: PaykitPaymentRequestDiagnostics, private val refreshContactPaykitLink: RefreshContactPaykitLinkUseCase, private val usdtRepo: UsdtRepo, + private val pubkySwapsRouteRepo: PubkySwapsRouteRepo, val paykitUsdtPayments: PaykitUsdtPaymentRepo, private val samRockRepo: SamRockRepo, private val appUpdateSheet: AppUpdateTimedSheet, @@ -3931,14 +3936,20 @@ class AppViewModel @Inject constructor( val canUseOnchain = address.isNotEmpty() && walletRepo.balanceState.value.maxSendOnchainSats > Defaults.dustLimit.toULong() val useUsdt = recipient != null && prefersUsdt(payment, hasBitcoinEndpoint, canUseLightning || canUseOnchain) - val payMethod = paykitBitcoinMethod(canUseLightning, address, invoice, lnurl) + val swapRoute = paykitSwapRoute(payment, address, canUseLightning || canUseOnchain || useUsdt) + val payMethod = if (swapRoute != null) { + SendMethod.LIGHTNING + } else { + paykitBitcoinMethod(canUseLightning, address, invoice, lnurl) + } _sendUiState.update { it.copy( address = address, addressInput = address, isAddressInputValid = address.isNotEmpty(), decodedInvoice = invoice, lnurl = lnurl, paykitUsesUsdt = useUsdt, usdtRecipient = recipient, amount = invoice?.amountSatoshis?.takeIf { payMethod == SendMethod.LIGHTNING } ?: 0u, paykitAmount = "", - payMethod = payMethod + payMethod = payMethod, + paykitSwapRoute = swapRoute, ) } if (usdtRepo.shouldRefresh(isUsdtEnabled.value)) usdtRepo.refresh(history = false) @@ -4071,6 +4082,22 @@ class AppViewModel @Inject constructor( return !canUseBitcoin && balance > 0u } + /** + * A contact whose only reachable endpoint is on-chain, paid from spending when savings cannot cover it: bitkit-core + * routes it through a Pubky Swaps provider. Incoming requests keep their own proof flow and are never swap-routed. + */ + private fun paykitSwapRoute( + payment: ContactPaymentContext, + address: String, + canPayDirectly: Boolean, + ): PaykitSwapRouteUi? { + if (!pubkySwapsRouteRepo.isEnabled || canPayDirectly || address.isEmpty()) return null + if (payment.incomingPaymentRequest != null) return null + if (walletRepo.balanceState.value.maxSendLightningSats == 0uL) return null + val methodId = payment.endpoints.firstOrNull { it.methodId.isOnchain }?.methodId ?: return null + return PaykitSwapRouteUi(endpoint = PaykitEndpoint(methodId = methodId.rawValue, value = address)) + } + private fun canFundPaykitLightning(invoice: LightningInvoice?, lnurl: LnurlParams.LnurlPay?): Boolean { if (invoice == null && lnurl == null) return false val amount = invoice?.amountSatoshis?.coerceAtLeast(1u) ?: lnurl?.data?.minSendableSat() ?: 1u @@ -4460,6 +4487,10 @@ class AppViewModel @Inject constructor( setSendEffect(SendEffect.NavigateToWithdrawConfirm) return } + _sendUiState.value.paykitSwapRoute?.let { + if (quotePaykitSwapRoute(it)) setSendEffect(SendEffect.NavigateToConfirm) + return + } _sendUiState.update { it.copy(isLoading = true) } try { @@ -4474,6 +4505,46 @@ class AppViewModel @Inject constructor( setSendEffect(SendEffect.NavigateToConfirm) } + private suspend fun quotePaykitSwapRoute(route: PaykitSwapRouteUi): Boolean { + _sendUiState.update { it.copy(isLoading = true) } + val balances = walletRepo.balanceState.value + val result = pubkySwapsRouteRepo.route( + amountSats = _sendUiState.value.amount, + endpoints = listOf(route.endpoint), + lightningSpendableSats = maxSendableLightningSats(), + onchainSpendableSats = balances.maxSendOnchainSats, + ) + _sendUiState.update { it.copy(isLoading = false) } + val paykitRoute = result.getOrElse { + Logger.error("Failed to route Paykit payment through Pubky Swaps", it, context = TAG) + toast(it) + return false + } + val quote = when (paykitRoute) { + is PaykitRoute.Swap -> paykitRoute.quote + is PaykitRoute.None -> { + toast(AppError(paykitRoute.reason)) + return false + } + // Balances changed after the contact opened: the regular flow must reopen it. + is PaykitRoute.Direct -> { + _sendUiState.update { it.copy(paykitSwapRoute = null) } + toast(AppError(context.getString(R.string.common__error_body))) + return false + } + } + _sendUiState.update { + it.copy( + paykitSwapRoute = route.copy( + quote = quote, + providerName = pubkySwapsRouteRepo.providerContact(quote.providerPubky)?.name, + ), + lightningFeeSats = quote.totalFeeSat.toLong(), + ) + } + return true + } + private suspend fun reviewPaykitAmount(navigateToUsdtReview: Boolean = true): Boolean { val paykit = synchronized(contactPaymentContextLock) { activeContactPaymentContext } if (paykit == null || paykit.endpoints.isEmpty()) return true @@ -4749,7 +4820,10 @@ class AppViewModel @Inject constructor( resetQuickPay() val fromMainScanner = isMainScanner - if (contactPaymentContext != null && requiresPaykitAssetSelection(contactPaymentContext)) { + if ( + contactPaymentContext != null && + (requiresPaykitAssetSelection(contactPaymentContext) || requiresPaykitSwapRoute(contactPaymentContext)) + ) { openPaykitContactPayment(contactPaymentContext, fromMainScanner, suppressQuickPay, walletGeneration) return@withContext } @@ -4875,6 +4949,13 @@ class AppViewModel @Inject constructor( } } + private fun requiresPaykitSwapRoute(payment: ContactPaymentContext): Boolean { + if (!pubkySwapsRouteRepo.isEnabled || payment.incomingPaymentRequest != null) return false + if (payment.endpoints.isEmpty() || !payment.endpoints.all { it.methodId.isOnchain }) return false + val balances = walletRepo.balanceState.value + return balances.maxSendOnchainSats <= Defaults.dustLimit.toULong() && balances.maxSendLightningSats > 0uL + } + private fun requiresPaykitAssetSelection(payment: ContactPaymentContext): Boolean = payment.endpoints.isNotEmpty() && ( payment.endpoints.any { it.methodId == MethodId.UsdtArbitrum } || @@ -6108,6 +6189,11 @@ class AppViewModel @Inject constructor( return } + _sendUiState.value.paykitSwapRoute?.quote?.let { + proceedWithSwapRoutedPayment(it, contactPaymentContext, allowanceAttemptId) + return + } + when (_sendUiState.value.payMethod) { SendMethod.ONCHAIN -> proceedWithOnchainPayment( incomingPaymentRequest, @@ -6482,6 +6568,42 @@ class AppViewModel @Inject constructor( ) } + private suspend fun proceedWithSwapRoutedPayment( + quote: PubkySwapRouteQuote, + contactPaymentContext: ContactPaymentContext?, + allowanceAttemptId: String?, + ) { + val payment = pubkySwapsRouteRepo.commit(quote.quoteId).getOrElse { + releasePrivatePaymentListIfNeeded(contactPaymentContext) + finishManualAllowancePayment(allowanceAttemptId, succeeded = false) + handlePaymentPreparationFailure(it, contactPaymentContext) + return + } + Logger.info("Committed Pubky Swaps route '${payment.swapId}' to '${payment.destinationAddress}'", context = TAG) + // The hold invoice settles only after the claim reveals the preimage, so its payment stays pending until then. + lightningRepo.payInvoice(payment.invoice).getOrElse { + releasePrivatePaymentListIfNeeded(contactPaymentContext) + finishManualAllowancePayment(allowanceAttemptId, succeeded = false) + handlePaymentPreparationFailure(it, contactPaymentContext) + return + } + val claimTxid = pubkySwapsRouteRepo.awaitClaim(payment.swapId).getOrElse { + finishManualAllowancePayment(allowanceAttemptId, succeeded = false) + handlePaymentPreparationFailure(it, contactPaymentContext) + return + } + finishManualAllowancePayment(allowanceAttemptId, succeeded = true) + Logger.info("Claimed Pubky Swaps route '${payment.swapId}' in '$claimTxid'", context = TAG) + onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = claimTxid, + sats = payment.deliveredSat.toLong(), + ), + ) + } + private suspend fun proceedWithLightningPayment( incomingPaymentRequest: PaykitPaymentRequest?, preparedPaymentProofRequest: PaykitPaymentRequest?, @@ -8860,6 +8982,7 @@ data class SendUiState( val requiresAmountReview: Boolean = false, val initialSubscriptionPaymentNeedsConfirmation: Boolean = false, val incomingPaymentRequestId: PaykitPaymentRequestId? = null, + val paykitSwapRoute: PaykitSwapRouteUi? = null, ) { val canSwitchBitcoinFundingSource: Boolean get() = fundingOptions.count { it.enabled && it.source != SendFundingSource.Usdt } > 1 @@ -8888,6 +9011,14 @@ data class SendUiState( !initialSubscriptionPaymentNeedsConfirmation && !requiresAmountReview } +/** A Paykit payment routed through a Pubky Swaps provider: the endpoint, the route once quoted, and its provider. */ +@Stable +data class PaykitSwapRouteUi( + val endpoint: PaykitEndpoint, + val quote: PubkySwapRouteQuote? = null, + val providerName: String? = null, +) + @Immutable data class OnchainFeeUi( val rate: FeeRate = FeeRate.fromSpeed(TransactionSpeed.default()), diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 045e83ac9b..5476d05814 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -977,6 +977,7 @@ جارٍ دفع\n<accent>الفاتورة...</accent> تأكيد تم إرسال Bitcoin + المسار اسحب للدفع إلى نعم، أرسل 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 2e3d9b1f9f..5206274597 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1010,6 +1010,7 @@ Pagando\n<accent>factura...</accent> Confirmar Bitcoin Enviado + Ruta Deslizar para pagar Para Sí, enviar diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 24dcb34d0a..d0a82d88f7 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1003,6 +1003,7 @@ Pagant\n<accent>factura...</accent> Confirmar Bitcoin enviat + Ruta Desplaça per pagar A Sí, Enviar diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index f0336264d1..ecd167d9ce 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1008,6 +1008,7 @@ Placení\n<accent>faktury...</accent> Potvrdit bitcoin odeslán + Trasa Zaplatit přejetím prstem Komu Ano, odeslat diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 2a45ff0fe4..a82ddaa7fe 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -858,6 +858,7 @@ Bestätigung in Kontakt auswählen Rechnungsverfall + Route Zum Bezahlen wischen Ja, senden Es scheint, dass du über $100 sendest. Möchtest du fortfahren? diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 8587ae5710..0150d22b5e 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -982,6 +982,7 @@ Πληρωμή\n<accent>τιμολογίου...</accent> Επιβεβαίωση Τα Bitcoin στάλθηκαν + Διαδρομή Σύρε για πληρωμή Προς Ναι, Στείλε diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 9d926c39bd..d9926bc422 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1003,6 +1003,7 @@ Pagando\n<accent>factura...</accent> Confirmar Bitcoin Enviado + Ruta Deslizar para pagar Para Sí, enviar diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 940f3de425..fcb9036536 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -850,6 +850,7 @@ Confirmando en Seleccionar contacto Expiración de la factura + Ruta Deslizar para pagar Sí, enviar Parece que está enviando más de 100 dólares. ¿Desea continuar? diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 61cd95d307..7bd8397d2a 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -859,6 +859,7 @@ Confirmation en Sélectionnez un contact Expiration de la facture + Itinéraire Glisser pour payer Oui, Envoyer Il apparaît que vous envoyez plus de 100 $. Voulez-vous continuer ? diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 2141ffc645..5aaed439be 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1006,6 +1006,7 @@ Pagamento\n<accent>fattura...</accent> Conferma Bitcoin Inviati + Percorso Scorri per Pagare A Si, Invia diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 0f16a7c531..878838f574 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1001,6 +1001,7 @@ Factuur\n<accent>betalen...</accent> Bevestigen Bitcoin verzonden + Route Veeg om te betalen Naar Ja, verzenden diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index d1a5a29132..bdaaa87833 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -861,6 +861,7 @@ Potwierdzenie w ciągu Wybierz kontakt Termin faktury + Trasa Przesuń, aby zapłacić Tak, wyślij Wygląda na to, że wysyłasz ponad $100. Czy kontynuować? diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 0266a4b1e4..923a807517 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1010,6 +1010,7 @@ Pagando \n<accent>invoice...</accent> Confirmar Bitcoin Enviado + Rota Deslize para Pagar Para Sim, Enviar diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index c9f113920f..0e397be809 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -722,6 +722,7 @@ Confirmar Confirmando em Vencimento do invoice + Rota Deslize para Pagar Sim, Enviar Parece que você está enviando mais de US$ 100. Deseja continuar? diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index cd61bf299f..796d825d09 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1026,6 +1026,7 @@ <accent>счёта...</accent> Подтвердить Биткоины отправлены + Маршрут Отправить Кому Да, Отправить diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e0f1418c67..9869929640 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1564,6 +1564,8 @@ Paying\n<accent>invoice...</accent> Confirm Bitcoin Sent + Route + Pubky Swaps · %1$s Swipe To Pay To Yes, Send diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 59d48a26ac..d34cd30575 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -196,6 +196,7 @@ import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyIdentityReadiness import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PubkySignIn +import to.bitkit.repositories.PubkySwapsRouteRepo import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.QuickPayCompletionKind @@ -296,6 +297,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val paykitPaymentProofRepo = mock() private val paykitPaymentRequestDiagnostics = mock() private val usdtRepo = mock() + private val pubkySwapsRouteRepo = mock() private val usdtState = MutableStateFlow(UsdtWalletState(balance = 1_000_000u)) private val paykitUsdtPayments = mock() private val samRockRepo = mock() @@ -943,6 +945,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { paykitPaymentRequestDiagnostics = paykitPaymentRequestDiagnostics, refreshContactPaykitLink = refreshContactPaykitLink, usdtRepo = usdtRepo, + pubkySwapsRouteRepo = pubkySwapsRouteRepo, paykitUsdtPayments = paykitUsdtPayments, samRockRepo = samRockRepo, appUpdateSheet = mock { on { type } doReturn TimedSheetType.APP_UPDATE }, diff --git a/journeys/paykit-swap-route.xml b/journeys/paykit-swap-route.xml new file mode 100644 index 0000000000..bb03778896 --- /dev/null +++ b/journeys/paykit-swap-route.xml @@ -0,0 +1,29 @@ + + + Proves that paying a Paykit contact whose only endpoint is on-chain, from a wallet that holds only a spending + balance, routes through a Pubky Swaps provider found among the user's own contacts, without the user picking a + swap, and that the contact receives the exact amount on chain. + + Preconditions: a dev debug build with E2E=true, E2E_BACKEND=local, E2E_HOMESERVER_PUBKY set to the local + marketplace homeserver and PUBKY_SWAPS_ROUTES=true; bitkit-docker's + default stack with the marketplace, payment-requests and lnurl-pay profiles; the provider running against + bitkit-docker's LND and funded; adb reverse for 6286, 6287, 6288, 15411, 15412, 60001, 23735, 23000 and 23010. + The wallet has a spending balance from an LNURL channel with bitkit-docker's LND and an empty savings balance, + a Pubky profile on the local homeserver, and the provider's pubky saved as a contact. + The contact is bitkit-docker's fixture-issuer, which publishes only a btc-regtest-p2wpkh endpoint. + + + Verify the savings balance (tag "ActivitySavings") shows 0 and the spending balance (tag "ActivitySpending") is above 25 000 + Tap Send (tag "Send"), then Enter Manually (tag "RecipientManual") + Type the fixture-issuer's pubky (from bitkit-docker's fixture-issuer /health) and tap Continue (tag "AddressContinue") + Verify the Add Contact screen is visible and tap Pay (tag "AddContactPay") + Verify the amount screen (tag "send_amount_screen") offers SPENDING as the source + Enter 20000 and tap Continue (tag "ContinueAmount") + Verify the Confirm screen (tag "SendConfirm") is visible, then tap Show Details (tag "SendConfirmToggleDetails") + Verify FROM is SPENDING, TO is the contact, and ROUTE (tag "ReviewSwapRoute") reads "Pubky Swaps" followed by the provider's shortened pubky + Swipe to pay (tag "GRAB") + Once the provider logs "HTLC funded; awaiting client claim", mine one block on bitkit-docker's bitcoind + Verify the Bitcoin Sent screen (tag "SendSuccess") is visible + Verify the provider logs "client claimed; hold invoice settled", and bitcoind shows the claim transaction paying exactly 0.0002 BTC to the fixture-issuer's address + + From 46ad2a981b67f456339962b8a22dd7d9c5adb775 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 22:52:04 +0100 Subject: [PATCH 2/7] chore: add changelog fragment --- changelog.d/next/1732.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1732.added.md diff --git a/changelog.d/next/1732.added.md b/changelog.d/next/1732.added.md new file mode 100644 index 0000000000..d7dfa80257 --- /dev/null +++ b/changelog.d/next/1732.added.md @@ -0,0 +1 @@ +Bitkit can pay a contact who only accepts on-chain bitcoin from your spending balance, through a Pubky Swaps provider among your contacts. From 0336ee0374d8d167f27ec34a169400e26df9db5b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 30 Oct 2026 02:19:18 +0100 Subject: [PATCH 3/7] docs: pay the journey's contact from the contact list --- journeys/paykit-swap-route.xml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/journeys/paykit-swap-route.xml b/journeys/paykit-swap-route.xml index bb03778896..5de3d268fa 100644 --- a/journeys/paykit-swap-route.xml +++ b/journeys/paykit-swap-route.xml @@ -9,14 +9,13 @@ default stack with the marketplace, payment-requests and lnurl-pay profiles; the provider running against bitkit-docker's LND and funded; adb reverse for 6286, 6287, 6288, 15411, 15412, 60001, 23735, 23000 and 23010. The wallet has a spending balance from an LNURL channel with bitkit-docker's LND and an empty savings balance, - a Pubky profile on the local homeserver, and the provider's pubky saved as a contact. + a Pubky profile on the local homeserver, and both the provider's and the fixture-issuer's pubkys saved as contacts. The contact is bitkit-docker's fixture-issuer, which publishes only a btc-regtest-p2wpkh endpoint. Verify the savings balance (tag "ActivitySavings") shows 0 and the spending balance (tag "ActivitySpending") is above 25 000 - Tap Send (tag "Send"), then Enter Manually (tag "RecipientManual") - Type the fixture-issuer's pubky (from bitkit-docker's fixture-issuer /health) and tap Continue (tag "AddressContinue") - Verify the Add Contact screen is visible and tap Pay (tag "AddContactPay") + Tap Send (tag "Send"), then Contact (tag "RecipientContact") + Verify the contact list shows both the fixture-issuer and the provider, and tap the fixture-issuer (tag "SendContact_" followed by its pubky) Verify the amount screen (tag "send_amount_screen") offers SPENDING as the source Enter 20000 and tap Continue (tag "ContinueAmount") Verify the Confirm screen (tag "SendConfirm") is visible, then tap Show Details (tag "SendConfirmToggleDetails") From 9bb81f86eaa6160e027c0f54b8b6565de8002bcb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 30 Oct 2026 04:14:49 +0100 Subject: [PATCH 4/7] chore: use bitkit-core 0.8.0-swaps.1 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9767d1a465..5569065ecb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 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-rc11" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.8.0-swaps.1" } paykit = { module = "com.synonym:paykit-android", version = "0.1.1-rc2" } bouncycastle-pkix-jdk = { module = "org.bouncycastle:bcpkix-jdk18on", version = "1.83" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } From 97749ce626baaeb13a7ed546fbd40d3bdd84b2ca Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 13:33:24 +0100 Subject: [PATCH 5/7] chore: move to bitkit-core 0.8.0-swaps.3 --- .../main/java/to/bitkit/repositories/PubkySwapsRouteRepo.kt | 3 +++ app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 6 ++++++ gradle/libs.versions.toml | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkySwapsRouteRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkySwapsRouteRepo.kt index 35532eb050..b808c2e701 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkySwapsRouteRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkySwapsRouteRepo.kt @@ -5,6 +5,7 @@ import com.synonym.bitkitcore.PaykitRoute import com.synonym.bitkitcore.PaykitRouteRequest import com.synonym.bitkitcore.PubkySwapRoutePayment import com.synonym.bitkitcore.PubkySwapRouteState +import com.synonym.bitkitcore.ServiceRouteLeg import com.synonym.bitkitcore.pubkySwapsAdvance import com.synonym.bitkitcore.pubkySwapsCommitRoute import com.synonym.bitkitcore.pubkySwapsRoutePaykitPayment @@ -57,6 +58,7 @@ class PubkySwapsRouteRepo @Inject constructor( endpoints: List, lightningSpendableSats: ULong, onchainSpendableSats: ULong, + serviceLegs: List = emptyList(), ): Result = withSecrets { mnemonic, passphrase -> // Providers come from the user's own graph: every contact is asked for its swap offer. val candidates = pubkyRepo.contacts.value.map { it.publicKey } + Env.pubkySwapsProviders @@ -69,6 +71,7 @@ class PubkySwapsRouteRepo @Inject constructor( onchainSpendableSat = onchainSpendableSats, providers = candidates, feeRateSatVb = CLAIM_FEE_RATE_SAT_VB, + serviceLegs = serviceLegs, ), network = Env.network.toCoreNetwork(), mnemonic = mnemonic, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 1584c31d62..2c3785fd6f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4526,6 +4526,12 @@ class AppViewModel @Inject constructor( toast(AppError(paykitRoute.reason)) return false } + // No service legs are passed in yet, so the router cannot pick one. + is PaykitRoute.Service -> { + Logger.error("Unexpected service route '${paykitRoute.leg.service}'", context = TAG) + toast(AppError(context.getString(R.string.common__error_body))) + return false + } // Balances changed after the contact opened: the regular flow must reopen it. is PaykitRoute.Direct -> { _sendUiState.update { it.copy(paykitSwapRoute = null) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5569065ecb..af2fd39c6c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 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-swaps.1" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.8.0-swaps.3" } paykit = { module = "com.synonym:paykit-android", version = "0.1.1-rc2" } bouncycastle-pkix-jdk = { module = "org.bouncycastle:bcpkix-jdk18on", version = "1.83" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } From f6f8ad3bebc81c262fb5c9d14aca441165b86054 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 21:10:18 +0100 Subject: [PATCH 6/7] feat: return swap routes to savings after a wallet restore --- .../repositories/PubkySwapsRouteRepo.kt | 25 ++++++++++- .../to/bitkit/viewmodels/WalletViewModel.kt | 29 +++++++++++++ app/src/main/res/values-ar/strings.xml | 2 + app/src/main/res/values-b+es+419/strings.xml | 2 + app/src/main/res/values-ca/strings.xml | 2 + app/src/main/res/values-cs/strings.xml | 2 + app/src/main/res/values-de/strings.xml | 2 + app/src/main/res/values-el/strings.xml | 2 + app/src/main/res/values-es-rES/strings.xml | 2 + app/src/main/res/values-es/strings.xml | 2 + app/src/main/res/values-fr/strings.xml | 2 + app/src/main/res/values-it/strings.xml | 2 + app/src/main/res/values-nl/strings.xml | 2 + app/src/main/res/values-pl/strings.xml | 2 + app/src/main/res/values-pt-rBR/strings.xml | 2 + app/src/main/res/values-pt/strings.xml | 2 + app/src/main/res/values-ru/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../java/to/bitkit/ui/WalletViewModelTest.kt | 42 +++++++++++++++++++ gradle/libs.versions.toml | 2 +- 20 files changed, 127 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkySwapsRouteRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkySwapsRouteRepo.kt index b808c2e701..d72db02079 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkySwapsRouteRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkySwapsRouteRepo.kt @@ -8,6 +8,7 @@ import com.synonym.bitkitcore.PubkySwapRouteState import com.synonym.bitkitcore.ServiceRouteLeg import com.synonym.bitkitcore.pubkySwapsAdvance import com.synonym.bitkitcore.pubkySwapsCommitRoute +import com.synonym.bitkitcore.pubkySwapsRestore import com.synonym.bitkitcore.pubkySwapsRoutePaykitPayment import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.delay @@ -39,6 +40,7 @@ class PubkySwapsRouteRepo @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val keychain: Keychain, private val pubkyRepo: PubkyRepo, + private val lightningRepo: LightningRepo, ) { companion object { private const val TAG = "PubkySwapsRouteRepo" @@ -60,8 +62,7 @@ class PubkySwapsRouteRepo @Inject constructor( onchainSpendableSats: ULong, serviceLegs: List = emptyList(), ): Result = withSecrets { mnemonic, passphrase -> - // Providers come from the user's own graph: every contact is asked for its swap offer. - val candidates = pubkyRepo.contacts.value.map { it.publicKey } + Env.pubkySwapsProviders + val candidates = candidates() Logger.debug("Routing Paykit payment via '${candidates.map(PubkyPublicKeyFormat::redacted)}'", context = TAG) pubkySwapsRoutePaykitPayment( request = PaykitRouteRequest( @@ -79,6 +80,26 @@ class PubkySwapsRouteRepo @Inject constructor( ) } + /** + * Rebuilds the swaps this wallet started from each provider's listing, after a restore from the seed. The listing + * does not carry the contact's address, so a restored swap claims to a fresh Savings address. Returns the swap ids. + */ + suspend fun restoreSwaps(): Result> = withSecrets { mnemonic, passphrase -> + val payoutAddress = lightningRepo.newAddress().getOrThrow() + pubkySwapsRestore( + providers = candidates(), + electrumUrl = Env.electrumServerUrl, + payoutAddress = payoutAddress, + feeRateSatVb = CLAIM_FEE_RATE_SAT_VB, + network = Env.network.toCoreNetwork(), + mnemonic = mnemonic, + bip39Passphrase = passphrase, + ) + } + + /** Providers come from the user's own graph: every contact is asked for its swap offer. */ + private fun candidates(): List = pubkyRepo.contacts.value.map { it.publicKey } + Env.pubkySwapsProviders + suspend fun commit(quoteId: String): Result = withSecrets { mnemonic, passphrase -> pubkySwapsCommitRoute(quoteId, Env.electrumServerUrl, mnemonic, passphrase) } diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 7dfdfd4ad8..d9420b34b9 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -48,6 +48,7 @@ import to.bitkit.repositories.ConnectivityState import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.NodeStartYieldedToStopError import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PubkySwapsRouteRepo import to.bitkit.repositories.RecoveryModeError import to.bitkit.repositories.SyncSource import to.bitkit.repositories.WalletRepo @@ -79,6 +80,7 @@ class WalletViewModel @Inject constructor( private val connectivityRepo: ConnectivityRepo, private val boltzService: BoltzService, private val connectionRecovery: WalletConnectionRecovery, + private val pubkySwapsRouteRepo: PubkySwapsRouteRepo, ) : ViewModel() { companion object { private const val TAG = "WalletViewModel" @@ -492,6 +494,7 @@ class WalletViewModel @Inject constructor( } }.onSuccess { _restoreState.update { RestoreState.Completed } + restorePubkySwaps() }.onFailure { Logger.error("Restore from backup failed", it, context = TAG) ToastEventBus.send(it) @@ -561,6 +564,32 @@ class WalletViewModel @Inject constructor( _restoreState.update { RestoreState.Settled } } + /** + * Finishes swap routes a restored wallet left in flight: each is rebuilt from its provider's listing and claimed to + * Savings, since the contact's address did not survive the restore, so the user learns the payment came back. + */ + private fun restorePubkySwaps() { + if (!pubkySwapsRouteRepo.isEnabled) return + viewModelScope.launch(bgDispatcher) { + val restored = pubkySwapsRouteRepo.restoreSwaps().getOrElse { + Logger.warn("Failed to restore Pubky Swaps routes", it, context = TAG) + return@launch + } + Logger.info("Restored '${restored.size}' Pubky Swaps routes", context = TAG) + restored.forEach { swapId -> + launch { + pubkySwapsRouteRepo.awaitClaim(swapId).onSuccess { + ToastEventBus.send( + type = Toast.ToastType.INFO, + title = context.getString(R.string.wallet__swap_route_restored_title), + description = context.getString(R.string.wallet__swap_route_restored_text), + ) + } + } + } + } + } + fun onRestoreRetry() = requestRecoveryRetry(owner = null, editorId = null) private suspend fun retryRecovery(owner: WalletConnectionRecovery.Owner, backupOnly: Boolean, attempt: Long) { diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 5476d05814..a7418da19e 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -983,6 +983,8 @@ نعم، أرسل <accent>أرسل\nBitcoin</accent>\nإلى\nرصيد إنفاقك الإنفاق + دفعة لجهة اتصال قاطعتها الاستعادة عادت إلى مدخراتك. + أُعيدت الدفعة إلى المدخرات العلامات إضافة علامة إضافة 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 5206274597..d014e3ba39 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1016,6 +1016,8 @@ Sí, enviar <accent>Enviar\nbitcoin</accent>\na su\nsaldo de gastos Gasto + Un pago a un contacto que la restauración interrumpió volvió a tus ahorros. + Pago devuelto a Ahorros Etiquetas Añadir etiqueta Añadir diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index d0a82d88f7..cee57ddeae 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1009,6 +1009,8 @@ Sí, Enviar <accent>Envia\nbitcoin</accent>\nal teu\nsaldo de despesa Despesa + Un pagament a un contacte que la restauració va interrompre ha tornat als teus estalvis. + Pagament retornat a Estalvis Etiquetes Afegeix etiqueta Afegir diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index ecd167d9ce..55b390c650 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1014,6 +1014,8 @@ Ano, odeslat <accent>Odeslat\nbitcoin</accent>\ndo vašeho\ndisponibilního zůstatku Útraty + Platba kontaktu přerušená obnovou se vrátila do vašich úspor. + Platba vrácena do Úspor Tagy Vložit tag Vložit diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a82ddaa7fe..34eb4c8352 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -892,6 +892,8 @@ Notiz Kommentar Optionaler Kommentar zum Empfänger + Eine Zahlung an einen Kontakt, die die Wiederherstellung unterbrochen hat, ist in deine Ersparnisse zurückgeflossen. + Zahlung an Ersparnisse zurückgegeben Tags Tag hinzufügen Hinzufügen diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 0150d22b5e..3ee672943f 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -988,6 +988,8 @@ Ναι, Στείλε <accent>Στείλε\nbitcoin</accent>\nστο υπόλοιπο\nδαπανών Δαπάνες + Μια πληρωμή σε επαφή που διέκοψε η επαναφορά επέστρεψε στις αποταμιεύσεις σου. + Η πληρωμή επέστρεψε στις Αποταμιεύσεις Ετικέτες Προσθήκη ετικέτας Προσθήκη diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index d9926bc422..0f16718489 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1009,6 +1009,8 @@ Sí, enviar <accent>Envía\nbitcoin</accent>\na tu\nsaldo de gastos Gasto + Un pago a un contacto que la restauración interrumpió ha vuelto a tus ahorros. + Pago devuelto a Ahorros Etiquetas Añadir etiqueta Añadir diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index fcb9036536..5271fb7b36 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -882,6 +882,8 @@ ₿ {feeSats} para esta transacción ₿ {feeSats} para esta transacción ({fiatSymbol}{fiatFormatted}) Nota + Un pago a un contacto que la restauración interrumpió ha vuelto a tus ahorros. + Pago devuelto a Ahorros Etiquetas Añadir etiqueta Añadir diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 7bd8397d2a..5303d6d9a9 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -889,6 +889,8 @@ Note Commentaire Commentaire facultatif au destinataire + Un paiement à un contact interrompu par la restauration est revenu sur ton épargne. + Paiement renvoyé vers l\'Épargne Tags Ajouter une étiquette Ajouter diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 5aaed439be..924538f2d5 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1012,6 +1012,8 @@ Si, Invia <accent>Invia\nbitcoin</accent>\nal tuo\nconto di spesa Spesa + Un pagamento a un contatto interrotto dal ripristino è tornato nei tuoi risparmi. + Pagamento restituito ai Risparmi Tag Aggiungi Tag Aggiungi diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 878838f574..039ed9cd36 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1007,6 +1007,8 @@ Ja, verzenden <accent>Stuur\nbitcoin</accent>\nnaar je\nbestedingssaldo Bestedingssaldo + Een betaling aan een contact die door het herstel werd onderbroken, is teruggekomen op je spaargeld. + Betaling teruggestort naar Spaargeld Tags Tag toevoegen Toevoegen diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index bdaaa87833..0efbd2a276 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -891,6 +891,8 @@ Notatka Komentarz Opcjonalny komentarz dla odbiorcy + Płatność do kontaktu przerwana przez przywracanie wróciła do twoich oszczędności. + Płatność zwrócona do Oszczędności Tagi Dodaj tag Dodaj diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 923a807517..57e149f1fc 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1016,6 +1016,8 @@ Sim, Enviar <accent>Transfira\nbitcoin</accent>\npara seu\nsaldo de gastos Gastos + Um pagamento a um contato que a restauração interrompeu voltou para sua poupança. + Pagamento devolvido à Poupança Tags Adicionar Tag Adicionar diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 0e397be809..9604c8ed31 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -751,6 +751,8 @@ Nota Comentário Comentário opcional para o receptor + Um pagamento a um contacto que o restauro interrompeu voltou às tuas poupanças. + Pagamento devolvido às Poupanças Tags Adicionar Tag Adicionar diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 796d825d09..5f7524c024 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1032,6 +1032,8 @@ Да, Отправить <accent>Отправьте\nбиткоин</accent>\nна ваш\nБаланс Расходов Расходы + Платёж контакту, прерванный восстановлением, вернулся в ваши сбережения. + Платёж возвращён в Сбережения Теги Добавить Тег Добавить diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9869929640..c6ee4f25db 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1571,6 +1571,8 @@ Yes, Send <accent>Send\nbitcoin</accent>\nto your\nspending balance Spending + A contact payment the restore interrupted came back to your savings. + Payment returned to Savings Tags Add Tag Add diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 0285ec4960..5a349a5a87 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -49,6 +49,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.NodeStartYieldedToStopError import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PubkySwapsRouteRepo import to.bitkit.repositories.SyncSource import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WalletState @@ -86,6 +87,7 @@ class WalletViewModelTest : BaseUnitTest() { private val migrationService = mock() private val connectivityRepo = mock() private val boltzService = mock() + private val pubkySwapsRouteRepo = mock() private val lightningState = MutableStateFlow(LightningState()) private val walletState = MutableStateFlow(WalletState()) @@ -126,6 +128,7 @@ class WalletViewModelTest : BaseUnitTest() { connectivityRepo = connectivityRepo, boltzService = boltzService, connectionRecovery = connectionRecovery, + pubkySwapsRouteRepo = pubkySwapsRouteRepo, ) } @@ -344,6 +347,40 @@ class WalletViewModelTest : BaseUnitTest() { assertFalse(settingsData.value.pendingRestoreActivitySeen) } + @Test + fun `restores swap routes once a wallet restore completes`() = test { + whenever(pubkySwapsRouteRepo.isEnabled).thenReturn(true) + whenever(pubkySwapsRouteRepo.restoreSwaps()).thenReturn(Result.success(listOf("swap"))) + whenever(pubkySwapsRouteRepo.awaitClaim(eq("swap"), any())).thenReturn(Result.success("txid")) + whenever(context.getString(any())).thenReturn("text") + whenever(backupRepo.getLatestBackupTime()).thenReturn(1uL) + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())).thenReturn(Result.success(Unit)) + lightningState.value = lightningState.value.copy(nodeLifecycleState = NodeLifecycleState.Running) + + sut.restoreWallet("mnemonic", "passphrase") + walletState.value = walletState.value.copy(walletExists = true) + advanceUntilIdle() + + assertEquals(RestoreState.Completed, sut.restoreState.value) + verify(pubkySwapsRouteRepo).restoreSwaps() + verify(pubkySwapsRouteRepo).awaitClaim(eq("swap"), any()) + } + + @Test + fun `leaves swap routes alone after a restore while they are off`() = test { + whenever(pubkySwapsRouteRepo.isEnabled).thenReturn(false) + whenever(backupRepo.getLatestBackupTime()).thenReturn(1uL) + whenever(backupRepo.performFullRestoreFromLatestBackup(any(), anyOrNull())).thenReturn(Result.success(Unit)) + lightningState.value = lightningState.value.copy(nodeLifecycleState = NodeLifecycleState.Running) + + sut.restoreWallet("mnemonic", "passphrase") + walletState.value = walletState.value.copy(walletExists = true) + advanceUntilIdle() + + assertEquals(RestoreState.Completed, sut.restoreState.value) + verify(pubkySwapsRouteRepo, never()).restoreSwaps() + } + @Test fun `failed backup restore can retry without restarting node`() = test { val testError = AppError("restore failed") @@ -1164,6 +1201,7 @@ class WalletViewModelTest : BaseUnitTest() { connectivityRepo = connectivityRepo, boltzService = boltzService, connectionRecovery = connectionRecovery, + pubkySwapsRouteRepo = pubkySwapsRouteRepo, ) assertEquals(RestoreState.Initial, testSut.restoreState.value) @@ -1231,6 +1269,7 @@ class WalletViewModelTest : BaseUnitTest() { connectivityRepo = connectivityRepo, boltzService = boltzService, connectionRecovery = connectionRecovery, + pubkySwapsRouteRepo = pubkySwapsRouteRepo, ) testSut.start() @@ -1287,6 +1326,7 @@ class WalletViewModelTest : BaseUnitTest() { connectivityRepo = connectivityRepo, boltzService = boltzService, connectionRecovery = connectionRecovery, + pubkySwapsRouteRepo = pubkySwapsRouteRepo, ) // Trigger restore to put state in non-idle @@ -1327,6 +1367,7 @@ class WalletViewModelTest : BaseUnitTest() { connectivityRepo = connectivityRepo, boltzService = boltzService, connectionRecovery = connectionRecovery, + pubkySwapsRouteRepo = pubkySwapsRouteRepo, ) testSut.restoreWallet("mnemonic", null) @@ -1418,6 +1459,7 @@ class WalletViewModelTest : BaseUnitTest() { connectivityRepo = connectivityRepo, boltzService = boltzService, connectionRecovery = connectionRecovery, + pubkySwapsRouteRepo = pubkySwapsRouteRepo, ) advanceUntilIdle() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index af2fd39c6c..434905884b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 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-swaps.3" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.8.0-swaps.4" } paykit = { module = "com.synonym:paykit-android", version = "0.1.1-rc2" } bouncycastle-pkix-jdk = { module = "org.bouncycastle:bcpkix-jdk18on", version = "1.83" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } From 257190c692062859e990ba82cffe8ca7330d151b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 2 Nov 2026 23:04:39 +0100 Subject: [PATCH 7/7] fix: keep the swap restore notice until dismissed --- .../to/bitkit/viewmodels/WalletViewModel.kt | 3 +++ journeys/paykit-swap-restore.xml | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 journeys/paykit-swap-restore.xml diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index d9420b34b9..0cc3c41f38 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -579,10 +579,13 @@ class WalletViewModel @Inject constructor( restored.forEach { swapId -> launch { pubkySwapsRouteRepo.awaitClaim(swapId).onSuccess { + // The claim also opens the received sheet above the toasts: stay until dismissed so the user + // still sees why the money came back once that sheet closes. ToastEventBus.send( type = Toast.ToastType.INFO, title = context.getString(R.string.wallet__swap_route_restored_title), description = context.getString(R.string.wallet__swap_route_restored_text), + autoHide = false, ) } } diff --git a/journeys/paykit-swap-restore.xml b/journeys/paykit-swap-restore.xml new file mode 100644 index 0000000000..7b2e2ea2c1 --- /dev/null +++ b/journeys/paykit-swap-restore.xml @@ -0,0 +1,24 @@ + + + Proves that a swap route the wallet left in flight before its claim is finished after the wallet is wiped and + restored from its recovery phrase: the restore finds the swap at its provider, claims it to Savings (the contact's + address does not survive the restore), and tells the user the payment was returned to Savings. + + Preconditions: as paykit-swap-route.xml, with the provider running against bitkit-docker (`--no-homeserver`) and + the build's PUBKY_SWAPS_PROVIDERS naming it. A throwaway regtest wallet restored from a recovery phrase, with a + spending balance from an LNURL channel, an empty savings balance, a Pubky profile, and the provider and the + fixture-issuer saved as contacts. Start a 20 000 sat payment to the fixture-issuer through the swap route, wait + until the provider logs "HTLC funded; awaiting client claim", and force-stop the app before any block is mined. + Then clear the app's data and enter the same recovery phrase on the restore screen. + + + Tap Restore (tag "RestoreButton") and wait for the Wallet Restored screen, then tap Get Started (tag "GetStartedButton") + Verify the savings balance (tag "ActivitySavings") shows 0 + Mine one block on bitkit-docker's bitcoind, so the provider's lockup confirms + Verify the provider logs "client claimed; hold invoice settled" + Mine one more block, so the claim confirms + Verify the "Received Bitcoin" sheet shows 20 000, then close it + Verify the toast "Payment returned to Savings" is visible + Verify the savings balance (tag "ActivitySavings") shows 20 000, and bitcoind shows the claim transaction paying exactly 0.0002 BTC to the wallet's own address + +