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..d72db02079 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PubkySwapsRouteRepo.kt @@ -0,0 +1,133 @@ +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.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 +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, + private val lightningRepo: LightningRepo, +) { + 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, + serviceLegs: List = emptyList(), + ): Result = withSecrets { mnemonic, passphrase -> + val candidates = candidates() + 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, + serviceLegs = serviceLegs, + ), + network = Env.network.toCoreNetwork(), + mnemonic = mnemonic, + bip39Passphrase = passphrase, + ) + } + + /** + * 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) + } + + /** 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..2c3785fd6f 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,52 @@ 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 + } + // 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) } + 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 +4826,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 +4955,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 +6195,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 +6574,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 +8988,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 +9017,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/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 7dfdfd4ad8..0cc3c41f38 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,35 @@ 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 { + // 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, + ) + } + } + } + } + } + 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 045e83ac9b..a7418da19e 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -977,11 +977,14 @@ جارٍ دفع\n<accent>الفاتورة...</accent> تأكيد تم إرسال Bitcoin + المسار اسحب للدفع إلى نعم، أرسل <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 2e3d9b1f9f..d014e3ba39 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1010,11 +1010,14 @@ Pagando\n<accent>factura...</accent> Confirmar Bitcoin Enviado + Ruta Deslizar para pagar Para 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 24dcb34d0a..cee57ddeae 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1003,11 +1003,14 @@ Pagant\n<accent>factura...</accent> Confirmar Bitcoin enviat + Ruta Desplaça per pagar A 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 f0336264d1..55b390c650 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1008,11 +1008,14 @@ Placení\n<accent>faktury...</accent> Potvrdit bitcoin odeslán + Trasa Zaplatit přejetím prstem Komu 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 2a45ff0fe4..34eb4c8352 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? @@ -891,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 8587ae5710..3ee672943f 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -982,11 +982,14 @@ Πληρωμή\n<accent>τιμολογίου...</accent> Επιβεβαίωση Τα Bitcoin στάλθηκαν + Διαδρομή Σύρε για πληρωμή Προς Ναι, Στείλε <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 9d926c39bd..0f16718489 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1003,11 +1003,14 @@ Pagando\n<accent>factura...</accent> Confirmar Bitcoin Enviado + Ruta Deslizar para pagar Para 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 940f3de425..5271fb7b36 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? @@ -881,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 61cd95d307..5303d6d9a9 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 ? @@ -888,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 2141ffc645..924538f2d5 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1006,11 +1006,14 @@ Pagamento\n<accent>fattura...</accent> Conferma Bitcoin Inviati + Percorso Scorri per Pagare A 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 0f16a7c531..039ed9cd36 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1001,11 +1001,14 @@ Factuur\n<accent>betalen...</accent> Bevestigen Bitcoin verzonden + Route Veeg om te betalen Naar 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 d1a5a29132..0efbd2a276 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ć? @@ -890,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 0266a4b1e4..57e149f1fc 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1010,11 +1010,14 @@ Pagando \n<accent>invoice...</accent> Confirmar Bitcoin Enviado + Rota Deslize para Pagar Para 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 c9f113920f..9604c8ed31 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? @@ -750,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 cd61bf299f..5f7524c024 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1026,11 +1026,14 @@ <accent>счёта...</accent> Подтвердить Биткоины отправлены + Маршрут Отправить Кому Да, Отправить <accent>Отправьте\nбиткоин</accent>\nна ваш\nБаланс Расходов Расходы + Платёж контакту, прерванный восстановлением, вернулся в ваши сбережения. + Платёж возвращён в Сбережения Теги Добавить Тег Добавить diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e0f1418c67..c6ee4f25db 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1564,11 +1564,15 @@ Paying\n<accent>invoice...</accent> Confirm Bitcoin Sent + Route + Pubky Swaps · %1$s Swipe To Pay To 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/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/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. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9767d1a465..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-rc11" } +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" } 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 + + diff --git a/journeys/paykit-swap-route.xml b/journeys/paykit-swap-route.xml new file mode 100644 index 0000000000..5de3d268fa --- /dev/null +++ b/journeys/paykit-swap-route.xml @@ -0,0 +1,28 @@ + + + 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 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 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") + 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 + +