diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 04873d1fbc..4974812897 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") @@ -364,6 +366,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 09b2fd0dab..47b4858539 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -62,6 +62,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..a2a9c36237 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PubkySwapsRouteRepo.kt @@ -0,0 +1,127 @@ +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.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 sparkRepo: SparkRepo, +) { + 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 + val serviceLegs = serviceLegs(endpoints, amountSats) + 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, + ) + } + + /** Legs through the services the wallet holds a balance on, which compete with the providers on fee. */ + private suspend fun serviceLegs(endpoints: List, amountSats: ULong): List = + endpoints.mapNotNull { endpoint -> + sparkRepo.withdrawalRouteLeg(endpoint, amountSats) + .onFailure { Logger.warn("No Spark route to '${endpoint.methodId}'", it, context = TAG) } + .getOrNull() + } + + /** Runs a service leg the router picked and returns the txid that pays the contact. */ + suspend fun payServiceLeg(leg: ServiceRouteLeg): Result = when (leg.service) { + SparkRepo.ROUTE_SERVICE -> sparkRepo.withdraw(leg.endpoint.value, leg.deliveredSat) + else -> Result.failure(AppError("Unsupported route service '${leg.service}'")) + } + + 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/repositories/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt index 22a7bbfef3..6b3fe7bb9e 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt @@ -13,8 +13,10 @@ import breez_sdk_spark.SdkException import breez_sdk_spark.SendPaymentMethod import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.LightningActivity +import com.synonym.bitkitcore.PaykitEndpoint import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.ServiceRouteLeg import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -63,6 +65,8 @@ class SparkRepo @Inject constructor( companion object { private const val TAG = "SparkRepo" private const val MSATS_PER_SAT = 1_000uL + const val ROUTE_SERVICE = "spark" + private const val ROUTE_ATOMICITY = "trusted" } private val scope = appScope(bgDispatcher, TAG) @@ -188,6 +192,41 @@ class SparkRepo @Inject constructor( } } + /** + * A Paykit route that withdraws exactly [amountSats] to the contact's on-chain [endpoint] at medium speed, with + * Spark's fee on top, or null while Spark cannot send it. bitkit-core weighs it against the Pubky Swaps providers. + */ + suspend fun withdrawalRouteLeg(endpoint: PaykitEndpoint, amountSats: ULong): Result = + withContext(bgDispatcher) { + runSuspendCatching { + if (!canSend(amountSats)) return@runSuspendCatching null + val method = sparkService.prepareSend(endpoint.value, amountSats).paymentMethod + val quote = (method as SendPaymentMethod.BitcoinAddress).feeQuote + val debitSats = amountSats + quote.speedMedium.userFeeSat + if (quote.isEstimate || !canSend(debitSats)) return@runSuspendCatching null + ServiceRouteLeg( + service = ROUTE_SERVICE, + endpoint = endpoint, + deliveredSat = amountSats, + debitSat = debitSats, + atomicity = ROUTE_ATOMICITY, + validUntilUnix = quote.expiresAt, + ) + } + } + + /** Withdraws exactly [amountSats] to the on-chain [address] at medium speed and returns the withdrawal's txid. */ + suspend fun withdraw(address: String, amountSats: ULong): Result = withContext(bgDispatcher) { + runSuspendCatching { + val payment = sparkService.send(sparkService.prepareSend(address, amountSats)) + Logger.info("Sent Spark withdrawal '${payment.id}' with status '${payment.status}'", context = TAG) + refresh() + (payment.details as? PaymentDetails.Withdraw)?.txId ?: payment.id + }.onFailure { + Logger.error("Failed to withdraw to a Paykit contact with Spark", it, context = TAG) + } + } + /** * Lists a deposit the app just broadcast as pending right away, then syncs so Spark sees it sooner than its * minute-long background sync; Spark's own deposit state replaces it once listed. 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 fd0ea27efc..18beebfdb8 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 @@ -59,6 +59,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 @@ -108,6 +109,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 @@ -868,6 +872,25 @@ private fun LightningDetails( } } + uiState.paykitSwapRoute?.let { route -> + val text = when { + route.serviceLeg != null -> stringResource(R.string.wallet__send_swap_route_spark) + route.quote != null -> stringResource( + R.string.wallet__send_swap_route_provider, + route.providerName?.takeIf { it.isNotBlank() } + ?: route.quote.providerPubky.ellipsisMiddle(PROVIDER_KEY_LENGTH), + ) + else -> return@let + } + SendCell(caption = stringResource(R.string.wallet__send_swap_route)) { + BodySSB( + text = text, + 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 4f4e77d604..20ad060192 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -27,8 +27,12 @@ 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.ServiceRouteLeg import com.synonym.bitkitcore.SortDirection import com.synonym.bitkitcore.UsdtQuote import com.synonym.paykit.PaymentConversion @@ -215,6 +219,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 @@ -319,6 +324,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, @@ -4027,14 +4033,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) @@ -4167,6 +4179,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 @@ -4556,6 +4584,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 { @@ -4570,6 +4602,57 @@ 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 + } + is PaykitRoute.Service -> { + val leg = paykitRoute.leg + _sendUiState.update { + it.copy( + paykitSwapRoute = route.copy(quote = null, serviceLeg = leg), + lightningFeeSats = (leg.debitSat - leg.deliveredSat).toLong(), + ) + } + return true + } + // 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, + serviceLeg = null, + 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 @@ -4845,7 +4928,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 } @@ -4971,6 +5057,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 } || @@ -6204,6 +6297,15 @@ class AppViewModel @Inject constructor( return } + _sendUiState.value.paykitSwapRoute?.serviceLeg?.let { + proceedWithServiceRoutedPayment(it, contactPaymentContext, allowanceAttemptId) + return + } + _sendUiState.value.paykitSwapRoute?.quote?.let { + proceedWithSwapRoutedPayment(it, contactPaymentContext, allowanceAttemptId) + return + } + when (_sendUiState.value.payMethod) { SendMethod.ONCHAIN -> proceedWithOnchainPayment( incomingPaymentRequest, @@ -6581,6 +6683,65 @@ class AppViewModel @Inject constructor( ) } + private suspend fun proceedWithServiceRoutedPayment( + leg: ServiceRouteLeg, + contactPaymentContext: ContactPaymentContext?, + allowanceAttemptId: String?, + ) { + val txid = pubkySwapsRouteRepo.payServiceLeg(leg).getOrElse { + releasePrivatePaymentListIfNeeded(contactPaymentContext) + finishManualAllowancePayment(allowanceAttemptId, succeeded = false) + handlePaymentPreparationFailure(it, contactPaymentContext) + return + } + finishManualAllowancePayment(allowanceAttemptId, succeeded = true, transactionId = txid) + Logger.info("Paid Paykit contact through '${leg.service}' in '$txid'", context = TAG) + onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = txid, + sats = leg.deliveredSat.toLong(), + ), + ) + } + + 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?, @@ -8968,6 +9129,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 @@ -8996,6 +9158,18 @@ data class SendUiState( !initialSubscriptionPaymentNeedsConfirmation && !requiresAmountReview } +/** + * A Paykit payment routed through a Pubky Swaps provider or a service the wallet holds a balance on: the endpoint, + * the route once quoted (a provider's quote or a service leg), and the provider's name. + */ +@Stable +data class PaykitSwapRouteUi( + val endpoint: PaykitEndpoint, + val quote: PubkySwapRouteQuote? = null, + val providerName: String? = null, + val serviceLeg: ServiceRouteLeg? = 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 099cc750f4..00556240e3 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1004,6 +1004,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 81544f0b31..778de70d5b 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1037,6 +1037,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 7c55f4893d..2e19557b97 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1030,6 +1030,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 19390b4801..bae647c25a 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1035,6 +1035,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 083fbebbde..7075acbfee 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -885,6 +885,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 de695bd160..699abf0165 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1009,6 +1009,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 46fb4c71a1..3454f75123 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1030,6 +1030,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 c6715e30d4..fa228febc7 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -877,6 +877,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 4aad110c9c..96edb44ad6 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -886,6 +886,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 c38eb548b3..5d8a3f0513 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1033,6 +1033,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 192b4c4ed3..b1c0dd25a5 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1028,6 +1028,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 9fab2bd67e..466d98f369 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -888,6 +888,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 901dafdf2a..99ff909558 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1037,6 +1037,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 8354feafe7..cc3b70e80f 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -749,6 +749,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 3d0e359968..50dc4c2ff6 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1053,6 +1053,7 @@ <accent>счёта...</accent> Подтвердить Биткоины отправлены + Маршрут Отправить Кому Да, Отправить diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9383871233..3d1549247a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1595,6 +1595,9 @@ Paying\n<accent>invoice...</accent> Confirm Bitcoin Sent + Route + Pubky Swaps · %1$s + Spark Swipe To Pay To Yes, Send diff --git a/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt b/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt index 0bedb23a26..020e53b21e 100644 --- a/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt @@ -1,5 +1,6 @@ package to.bitkit.repositories +import breez_sdk_spark.BitcoinAddressDetails import breez_sdk_spark.GetInfoResponse import breez_sdk_spark.ImportUnilateralExitStateResponse import breez_sdk_spark.Payment @@ -7,17 +8,24 @@ import breez_sdk_spark.PaymentDetails import breez_sdk_spark.PaymentMethod import breez_sdk_spark.PaymentStatus import breez_sdk_spark.PaymentType +import breez_sdk_spark.PrepareSendPaymentResponse import breez_sdk_spark.SdkEvent +import breez_sdk_spark.SendOnchainFeeQuote +import breez_sdk_spark.SendOnchainSpeedFeeQuote +import breez_sdk_spark.SendPaymentMethod import breez_sdk_spark.SparkHtlcDetails import breez_sdk_spark.SparkHtlcStatus import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.OnchainActivity +import com.synonym.bitkitcore.PaykitEndpoint +import com.synonym.bitkitcore.ServiceRouteLeg import kotlinx.coroutines.flow.MutableSharedFlow import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -124,6 +132,49 @@ class SparkRepoTest : BaseUnitTest() { assertEquals(listOf(SparkWithdrawal("withdrawal", 128uL, 1_470uL)), sut.state.value.withdrawals) } + @Test + fun `quotes a withdrawal to a Paykit contact as a route leg`() = test { + val sut = readySpark(balanceSats = 50_000uL) + + val leg = sut.withdrawalRouteLeg(contactEndpoint, 20_000uL).getOrThrow() + + assertEquals( + ServiceRouteLeg( + service = "spark", + endpoint = contactEndpoint, + deliveredSat = 20_000uL, + debitSat = 20_300uL, + atomicity = "trusted", + validUntilUnix = 1_900_000_000uL, + ), + leg, + ) + } + + @Test + fun `offers no route leg the balance cannot cover with its fee`() = test { + val sut = readySpark(balanceSats = 20_299uL) + + assertEquals(null, sut.withdrawalRouteLeg(contactEndpoint, 20_000uL).getOrThrow()) + } + + private val contactEndpoint = PaykitEndpoint(methodId = "btc-regtest-p2wpkh", value = "bcrt1qcontact") + + private suspend fun readySpark(balanceSats: ULong): SparkRepo { + whenever { sparkService.getInfo() }.thenReturn(GetInfoResponse("pubkey", balanceSats, emptyMap(), 0uL)) + val speed = SendOnchainSpeedFeeQuote(userFeeSat = 300uL, l1BroadcastFeeSat = 200uL) + val prepared = mock() + whenever(prepared.paymentMethod).thenReturn( + SendPaymentMethod.BitcoinAddress( + address = mock(), + feeQuote = SendOnchainFeeQuote("quote", 1_900_000_000uL, speed, speed, speed, isEstimate = false), + ), + ) + whenever { sparkService.prepareSend(eq("bcrt1qcontact"), eq(20_000uL), anyOrNull()) }.thenReturn(prepared) + whenever(sparkService.isConnected).thenReturn(true) + return createSut().also { it.start(0).getOrThrow() } + } + @Test fun `skips an exit backup from another network`() = test { val sut = createSut() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 0c434e5f14..01d2672f4b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -201,6 +201,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 @@ -304,6 +305,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() @@ -955,6 +957,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/changelog.d/next/1754.added.md b/changelog.d/next/1754.added.md new file mode 100644 index 0000000000..9dd64f6e8c --- /dev/null +++ b/changelog.d/next/1754.added.md @@ -0,0 +1 @@ +Paykit contacts who only accept on-chain bitcoin can be paid from Spark when that costs less than a swap. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a899b0db87..299aa02284 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.3" } breez-sdk-spark = { module = "breez_sdk_spark:bindings-android", version = "0.27.2" } paykit = { module = "com.synonym:paykit-android", version = "0.1.1-rc2" } bouncycastle-pkix-jdk = { module = "org.bouncycastle:bcpkix-jdk18on", version = "1.83" } diff --git a/journeys/paykit-spark-route.xml b/journeys/paykit-spark-route.xml new file mode 100644 index 0000000000..d2eec897a3 --- /dev/null +++ b/journeys/paykit-spark-route.xml @@ -0,0 +1,27 @@ + + + Proves that paying a Paykit contact whose only endpoint is on-chain, from a wallet whose spending balance is held + on Spark, routes through a Spark withdrawal straight to the contact's address, without the user picking a route, + 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 and + payment-requests profiles; adb reverse for 6286, 6287, 6288, 15411, 15412, 60001, 23735, 23000 and 23010. + The wallet has no channels and an empty savings balance, a Spark balance of at least 25 000 sats from Lightspark's + regtest faucet sent to the deposit address on the Spark screen, a Pubky profile on the local homeserver, and the + fixture-issuer's pubky saved as a contact. The contact is bitkit-docker's fixture-issuer, which publishes only a + btc-regtest-p2wpkh endpoint; Spark pays it on Lightspark's regtest chain. + + + 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") + 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 "Spark" + Swipe to pay (tag "GRAB") + Verify the Bitcoin Sent screen (tag "SendSuccess") shows 20 000 + Verify Lightspark's regtest mempool shows the withdrawal transaction paying exactly 20 000 sats to the fixture-issuer's 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 + +