From a35f7240b4350b2bc59736c7875014f4cc3be4a6 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 7 Oct 2026 15:20:14 -0500 Subject: [PATCH 1/5] chore: update paykit to rc71 --- 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 667d2568ba..5a100b2007 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.18" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc70" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc71" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From d1f8c744a1d45b3730ea96c465a5daa4852bf1ac Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 7 Oct 2026 16:33:52 -0500 Subject: [PATCH 2/5] fix: apply fixed bitcoin request prices --- .../PaykitBitcoinRequestPricing.kt | 55 +++++++++ .../repositories/PaykitPaymentRequestRepo.kt | 27 +++-- .../bitkit/repositories/PaykitSubscription.kt | 1 + .../PaykitPaymentRequestRepoTest.kt | 114 +++++++++++++++++- changelog.d/next/1437.fixed.md | 1 + docs/paykit-issuer-interoperability.md | 23 +++- journeys/payment-requests/README.md | 5 + .../payment-requests/fixed-price-bitcoin.xml | 17 +++ 8 files changed, 231 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/to/bitkit/repositories/PaykitBitcoinRequestPricing.kt create mode 100644 changelog.d/next/1437.fixed.md create mode 100644 journeys/payment-requests/fixed-price-bitcoin.xml diff --git a/app/src/main/java/to/bitkit/repositories/PaykitBitcoinRequestPricing.kt b/app/src/main/java/to/bitkit/repositories/PaykitBitcoinRequestPricing.kt new file mode 100644 index 0000000000..a2bc3474db --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitBitcoinRequestPricing.kt @@ -0,0 +1,55 @@ +package to.bitkit.repositories + +import com.synonym.paykit.PaymentConversion +import com.synonym.paykit.PaymentRequestTerms +import java.math.BigDecimal +import java.math.RoundingMode + +internal object PaykitBitcoinRequestPricing { + /** Decimal precision supported by both mobile platforms. */ + private const val MAX_SIGNIFICANT_DIGITS = 38 + + /** Maximum length of an amount or rate before decimal parsing. */ + private const val MAX_DECIMAL_LENGTH = 80 + + /** Millisatoshis per Bitcoin, expressed as a decimal scale. */ + private const val MILLISATOSHI_SCALE = 11 + + data class BitcoinPayment(val amountSats: ULong, val endpointIdentifiers: List) + + @Suppress("ReturnCount") + fun bitcoinPayment(terms: PaymentRequestTerms, endpoints: List): BitcoinPayment? { + val rates = (terms.conversion as? PaymentConversion.Fixed)?.rates ?: return null + if (rates.isEmpty() || rates.distinctBy { it.asset }.size != rates.size) return null + val requestedAmount = positiveDecimal(terms.amount.value) ?: return null + val amounts = mutableSetOf() + val payableEndpoints = mutableListOf() + for (endpoint in endpoints) { + val selector = endpoint.split('-').take(2).joinToString("-") + val rate = rates.firstOrNull { it.asset == selector } ?: rates.firstOrNull { it.asset == "btc" } + val rateValue = rate?.value ?: "1".takeIf { terms.amount.asset == "btc" } ?: continue + val multiplier = positiveDecimal(rateValue) ?: return null + val sats = sats(requestedAmount, multiplier, lightning = selector == "btc-lightning") ?: return null + amounts.add(sats) + payableEndpoints.add(endpoint) + } + return amounts.singleOrNull()?.let { BitcoinPayment(it, payableEndpoints) } + } + + private fun sats(amount: BigDecimal, rate: BigDecimal, lightning: Boolean): ULong? { + val bitcoin = amount.multiply(rate).stripTrailingZeros() + if (bitcoin.precision() > MAX_SIGNIFICANT_DIGITS) return null + val units = bitcoin.movePointRight(if (lightning) MILLISATOSHI_SCALE else 8).setScale(0, RoundingMode.CEILING) + val integer = units.toPlainString().toULongOrNull()?.takeIf { it > 0uL } ?: return null + if (lightning && integer % 1000uL != 0uL) return null + val sats = if (lightning) integer / 1000uL else integer + return sats.takeIf { it <= ULong.MAX_VALUE / 1000uL } + } + + private fun positiveDecimal(value: String): BigDecimal? { + if (value.length > MAX_DECIMAL_LENGTH) return null + if (!value.matches(Regex("(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)"))) return null + if (value.replace(".", "").trimStart('0').length > MAX_SIGNIFICANT_DIGITS) return null + return value.toBigDecimalOrNull()?.takeIf { it.signum() > 0 } + } +} diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 1eac6d4569..c993539ef4 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -107,6 +107,7 @@ data class PaykitPaymentRequest( RecurringRequest("recurring_request", shouldLogIncomingRejection = false), UnsupportedRecurrence("unsupported_recurrence"), UnsupportedAsset("unsupported_asset"), + UnsupportedPricing("unsupported_pricing"), UnsupportedPaymentDeadline("unsupported_payment_deadline"), InvalidAmount("invalid_amount"), AmountOutOfRange("amount_out_of_range"), @@ -1776,18 +1777,26 @@ private fun PaymentRequestRecord.parsePaykitPaymentRequest( if (requiresActionableRequest && paymentDeadlineAt?.let { now > it } == true) { return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.Expired) } - if (requestTerms.amount.asset != PaykitIssuerInterop.BITCOIN_ASSET) { - return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.UnsupportedAsset) - } - val amountSats = requestTerms.amount.value.toPaykitSats() - ?: return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.InvalidAmount) - if (amountSats > ULong.MAX_VALUE / 1000uL) { - return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.AmountOutOfRange) - } - val endpoints = PaykitIssuerInterop.supportedEndpointIdentifiers( + var endpoints = PaykitIssuerInterop.supportedEndpointIdentifiers( requestTerms.acceptedPaymentEndpointIdentifiers, network, ) + val amountSats: ULong + if (requestTerms.conversion != null) { + val payment = PaykitBitcoinRequestPricing.bitcoinPayment(requestTerms, endpoints) + ?: return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.UnsupportedPricing) + amountSats = payment.amountSats + endpoints = payment.endpointIdentifiers + } else { + if (requestTerms.amount.asset != PaykitIssuerInterop.BITCOIN_ASSET) { + return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.UnsupportedAsset) + } + amountSats = requestTerms.amount.value.toPaykitSats() + ?: return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.InvalidAmount) + if (amountSats > ULong.MAX_VALUE / 1000uL) { + return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.AmountOutOfRange) + } + } if (requiresActionableRequest && endpoints.isEmpty()) { return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.NoSupportedEndpoint) } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt index a49ae1103e..1f4a1ca2e6 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt @@ -298,6 +298,7 @@ internal fun PaymentRequestRecord.toPaykitSubscription( ): PaykitSubscription? { val role = localRole.toSubscriptionRole() ?: return null val requestTerms = terms ?: return null + if (requestTerms.conversion != null) return null val sdkRecurrence = requestTerms.recurrence ?: return null if ( requestTerms.amount.asset != "btc" || diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 8b123bd50e..6fdbb11b0e 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -6,6 +6,7 @@ import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.ValidationResult import com.synonym.bitkitcore.validateBitcoinAddress +import com.synonym.paykit.ConversionRate import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState @@ -13,6 +14,7 @@ import com.synonym.paykit.OutboundPrivateMessageStatus import com.synonym.paykit.OutboundPrivateSendFailure import com.synonym.paykit.OutboundPrivateSendReport import com.synonym.paykit.PaykitException +import com.synonym.paykit.PaymentConversion import com.synonym.paykit.PaymentDeadline import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentReference @@ -92,6 +94,115 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @Test + fun `fixed bitcoin pricing uses rail rates before asset rates and parity`() { + data class Case( + val asset: String, + val amount: String, + val rates: List, + val endpoints: List, + val expected: ULong, + ) + val cases = listOf( + Case("usd", "1000", listOf(ConversionRate("btc", "0.001")), listOf("btc-lightning-bolt11"), 100_000_000uL), + Case("usdt", "12.34", listOf(ConversionRate("btc", "0.00002")), listOf("btc-lightning-lnurl"), 24_680uL), + Case("btc", "0.001", listOf(ConversionRate("btc", "0.5")), listOf("btc-lightning-bolt11"), 50_000uL), + Case("btc", "0.001", listOf(ConversionRate("btc", "2")), listOf("btc-lightning-bolt11"), 200_000uL), + Case( + "btc", + "0.001", + listOf(ConversionRate("usdt", "1000")), + listOf("btc-regtest-p2wpkh", "usdt-ethereum-erc20"), + 100_000uL, + ), + Case( + "btc", + "0.001", + listOf(ConversionRate("btc", "2"), ConversionRate("btc-regtest", "0.5")), + listOf("btc-regtest-p2wpkh"), + 50_000uL, + ), + Case("usd", "1", listOf(ConversionRate("btc", "0.000000011")), listOf("btc-regtest-p2wpkh"), 2uL), + Case("usd", "1", listOf(ConversionRate("btc", "0.000000019999")), listOf("btc-lightning-bolt11"), 2uL), + Case( + "usd", + "1", + listOf(ConversionRate("btc", "0.00021")), + listOf("btc-regtest-p2wpkh", "btc-lightning-bolt11", "btc-lightning-lnurl"), + 21_000uL, + ), + ) + cases.forEach { + val record = paymentRequestRecord( + amount = it.amount, + asset = it.asset, + conversion = PaymentConversion.Fixed(it.rates), + endpoints = it.endpoints, + ) + val result = record.parseIncomingPaykitPaymentRequest(clock.now()) as PaykitPaymentRequestParseResult.Parsed + val request = result.request + assertEquals(it.expected, request.amountSats) + assertEquals(it.amount, request.amountValue) + assertEquals(it.asset, record.terms?.amount?.asset) + assertEquals(PaymentConversion.Fixed(it.rates), record.terms?.conversion) + assertTrue(request.acceptsPaymentAmount(it.expected)) + assertFalse(request.acceptsPaymentAmount(it.expected + 1uL)) + assertTrue(request.acceptsLightningInvoiceAmountMsats(it.expected * 1000uL)) + assertFalse(request.acceptsLightningInvoiceAmountMsats(it.expected * 1000uL + 1uL)) + } + } + + @Test + fun `fixed pricing omits unquoted cross asset rails`() { + val record = paymentRequestRecord( + amount = "1", + asset = "usd", + conversion = PaymentConversion.Fixed(listOf(ConversionRate("btc-regtest", "0.00021"))), + endpoints = listOf("btc-lightning-bolt11", "btc-regtest-p2wpkh", "btc-regtest-p2tr"), + ) + val result = record.parseIncomingPaykitPaymentRequest(clock.now()) as PaykitPaymentRequestParseResult.Parsed + assertEquals(21_000uL, result.request.amountSats) + assertEquals( + listOf("btc-regtest-p2wpkh", "btc-regtest-p2tr"), + result.request.acceptedPaymentEndpointIdentifiers, + ) + } + + @Test + fun `unsupported fixed pricing cannot become payable`() { + val rates = listOf( + emptyList(), listOf(ConversionRate("usdt", "1")), + listOf(ConversionRate("btc", "0")), listOf(ConversionRate("btc", "-1")), + listOf(ConversionRate("btc", "1e-3")), listOf(ConversionRate("btc", " 1")), + listOf(ConversionRate("btc", "1\n")), listOf(ConversionRate("btc", "184467440738")), + listOf(ConversionRate("btc", "0.000000011")), + listOf(ConversionRate("btc", "0.123456789012345678901234567890123456789")), + listOf(ConversionRate("btc", "1"), ConversionRate("btc", "2")), + listOf(ConversionRate("btc", "1"), ConversionRate("btc-lightning", "2")), + ) + (rates.map { PaymentConversion.Fixed(it) } + PaymentConversion.PerPeriod).forEach { + val record = paymentRequestRecord( + amount = "1", + asset = "usd", + conversion = it, + endpoints = listOf("btc-regtest-p2wpkh", "btc-lightning-bolt11"), + ) + assertEquals( + PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.UnsupportedPricing), + record.parseIncomingPaykitPaymentRequest(clock.now()), + ) + } + } + + @Test + fun `conversion subscriptions remain unsupported`() { + val record = paymentRequestRecord( + conversion = PaymentConversion.Fixed(listOf(ConversionRate("btc", "0.5"))), + recurrence = PaymentRequestRecurrence(1u, "month", "2027-01-01T00:00:00Z", "2027-01-01T00:00:00Z", null), + ) + assertNull(record.toPaykitSubscription()) + } + private val paykitSdkService = mock() private val settingsStore = mock() private val presentationStore = mock() @@ -2371,6 +2482,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { state: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROPOSED, amount: String = "0.001", asset: String = "btc", + conversion: PaymentConversion? = null, expiresAt: String? = null, paymentDeadline: PaymentDeadline? = null, endpoints: List = listOf(MethodId.Bolt11.rawValue), @@ -2393,7 +2505,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { proposalExpiresAt = expiresAt, recurrence = recurrence, acceptedPaymentEndpointIdentifiers = endpoints, - conversion = null, + conversion = conversion, paymentDeadline = paymentDeadline, metadata = metadata, paymentEndpoints = null, diff --git a/changelog.d/next/1437.fixed.md b/changelog.d/next/1437.fixed.md new file mode 100644 index 0000000000..254226a13f --- /dev/null +++ b/changelog.d/next/1437.fixed.md @@ -0,0 +1 @@ +Pay fixed-price Paykit requests in Bitcoin using the issuer's quoted amount. diff --git a/docs/paykit-issuer-interoperability.md b/docs/paykit-issuer-interoperability.md index 3c12fdc3f1..251bd510ae 100644 --- a/docs/paykit-issuer-interoperability.md +++ b/docs/paykit-issuer-interoperability.md @@ -9,8 +9,8 @@ This contract records Bitkit behavior. Paykit protocol or SDK policy remains own An actionable request must satisfy all of these requirements: -- The amount asset is exactly lowercase `btc`. -- The amount is a positive decimal Bitcoin value with at most eight significant fractional digits and no more than `18,446,744,073,709,551` satoshis. +- Without conversion terms, the amount asset is exactly lowercase `btc`, with a positive decimal Bitcoin value and at most eight significant fractional digits. +- With fixed conversion terms, the amount may use another denomination; Bitkit multiplies it by the issuer's BTC rate. The payable amount must not exceed `18,446,744,073,709,551` satoshis. - The request is one-time: the local role is payer, its actionable lifecycle state is proposed or accepted, and recurrence is absent. - The proposal expiration is absent or is a valid ISO 8601 timestamp. It must be in the future while the request is proposed; it does not prevent payment after acceptance. - `paymentDeadline` is absent or uses the absolute `At` form with a valid UTC ISO 8601 timestamp ending in `Z` that has not passed. The deadline is inclusive: payment is allowed at the exact deadline instant. @@ -22,6 +22,25 @@ Bitkit enforces absolute one-time payment deadlines during payment preparation a Malformed deadlines and relative deadline forms are not actionable. Recurring requests are outside this one-time contract. +### Fixed pricing + +An exact asset-and-rail rate (such as `btc-bitcoin` or `btc-regtest`) takes precedence +over a `btc` rate, including when the requested asset is already BTC. Only an unpriced +same-asset endpoint defaults to parity. Cross-asset endpoints without a matching rate +are excluded. Amounts and rates use positive decimal strings without signs or exponents; +Bitkit accepts at most 80 characters and 38 significant digits per operand and rejects +unrepresentable arithmetic instead of approximating it. + +On-chain amounts round upward once to satoshis. Lightning amounts round upward once +to millisatoshis and must then be exactly representable in whole satoshis. The existing +send sheet uses one amount across its available rails: requests with differing BTC +amounts across those rails, per-period quotes, and conversion-based subscriptions are +unsupported. Normal unpriced BTC requests are unchanged. + +Bitkit displays the payable BTC amount and its own market-rate fiat estimate, not the +original denomination as a fiat estimate. Original request terms remain unchanged in Paykit. +The quoted amount is also used for invoice matching and payment validation. + ### Endpoint identifiers Lightning identifiers are chain-independent and are accepted on every network: diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index a96332a4ce..407f614b39 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -4,6 +4,11 @@ Cover incoming Paykit Payment Requests from a linked issuer. The issuer contract [`docs/paykit-issuer-interoperability.md`](../../docs/paykit-issuer-interoperability.md) and [`app/src/test/resources/paykit-issuer-interoperability.json`](../../app/src/test/resources/paykit-issuer-interoperability.json). +`fixed-price-bitcoin.xml` verifies a USD-denominated request settled in BTC and an explicit +same-asset rail price. The confirmation amount uses the issuer's fixed rate; the displayed fiat +estimate uses Bitkit's own market rate. The flow stops before broadcasting. Differing rail prices, +fractional-satoshi Lightning payments, and dynamic quotes remain unsupported. + ## Setup Run Bitkit against regtest with Paykit UI enabled. Authenticate a Pubky identity, save and link the fixture issuer as a contact, and give the wallet enough on-chain balance to pay 100,000 sats. The fixture issuer must be able to publish a Paykit endpoint and send a one-time Payment Request to that linked peer. Its App ID is `paykit-server`; Bitkit uses `bitkit`. diff --git a/journeys/payment-requests/fixed-price-bitcoin.xml b/journeys/payment-requests/fixed-price-bitcoin.xml new file mode 100644 index 0000000000..e232904524 --- /dev/null +++ b/journeys/payment-requests/fixed-price-bitcoin.xml @@ -0,0 +1,17 @@ + + + Verifies fixed-price requests use the issuer's BTC quote while fiat display uses Bitkit's own exchange rate. Requires the linked fixture issuer and a regtest wallet funded for at least 100,000 sats plus fees, as described in README.md. This journey stops before broadcasting. + + + Launch the E2E Bitkit app with Paykit UI enabled and the fixture issuer saved as a contact and linked as identities + Have the issuer publish a current regtest P2WPKH address under identifier "btc-regtest-p2wpkh" with JSON payload {"value":"<current address>"} + Have the issuer send a new one-time request for amount "10", asset "usd", accepted identifier "btc-regtest-p2wpkh", and fixed conversion rate "btc" equal to "0.000021" + Verify the Payment Request confirmation screen (id "PaymentRequestConfirm") shows 21,000 sats, not 10 BTC + Wait for fresh validation and wallet preparation to finish; verify the same sheet stays open and its payment swipe control (id "GRAB") stops showing progress + Verify the fiat estimate equals Bitkit's current local valuation of 21,000 sats, rather than being forced to the issuer's 10 USD denomination + Dismiss the Payment Request confirmation screen without paying + Have the issuer send a new one-time request for amount "0.001", asset "btc", accepted identifier "btc-regtest-p2wpkh", and fixed rates "btc" equal to "2" and "btc-regtest" equal to "0.5" + Verify the Payment Request confirmation screen (id "PaymentRequestConfirm") shows 50,000 sats, not 100,000 or 200,000 sats + Dismiss the Payment Request confirmation screen without paying + + From 077f83e83ed21e7fb73954d38583bb7bcfadff9c Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 8 Oct 2026 08:01:47 -0500 Subject: [PATCH 3/5] chore: align fixed-price docs and tests --- .../to/bitkit/repositories/PaykitBitcoinRequestPricing.kt | 8 +++++--- .../bitkit/repositories/PaykitPaymentRequestRepoTest.kt | 8 +++++--- docs/paykit-issuer-interoperability.md | 5 +++++ journeys/payment-requests/fixed-price-bitcoin.xml | 2 +- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitBitcoinRequestPricing.kt b/app/src/main/java/to/bitkit/repositories/PaykitBitcoinRequestPricing.kt index a2bc3474db..7251cc5146 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitBitcoinRequestPricing.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitBitcoinRequestPricing.kt @@ -6,7 +6,7 @@ import java.math.BigDecimal import java.math.RoundingMode internal object PaykitBitcoinRequestPricing { - /** Decimal precision supported by both mobile platforms. */ + /** Maximum precision of decimal inputs and normalized products. */ private const val MAX_SIGNIFICANT_DIGITS = 38 /** Maximum length of an amount or rate before decimal parsing. */ @@ -26,8 +26,10 @@ internal object PaykitBitcoinRequestPricing { val payableEndpoints = mutableListOf() for (endpoint in endpoints) { val selector = endpoint.split('-').take(2).joinToString("-") - val rate = rates.firstOrNull { it.asset == selector } ?: rates.firstOrNull { it.asset == "btc" } - val rateValue = rate?.value ?: "1".takeIf { terms.amount.asset == "btc" } ?: continue + val rate = rates.firstOrNull { it.asset == selector } + ?: rates.firstOrNull { it.asset == PaykitIssuerInterop.BITCOIN_ASSET } + val rateValue = rate?.value + ?: "1".takeIf { terms.amount.asset == PaykitIssuerInterop.BITCOIN_ASSET } ?: continue val multiplier = positiveDecimal(rateValue) ?: return null val sats = sats(requestedAmount, multiplier, lightning = selector == "btc-lightning") ?: return null amounts.add(sats) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 6fdbb11b0e..34698bb2ab 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -61,6 +61,7 @@ import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.safe import to.bitkit.services.PaykitPaymentRequestProposalTerms import to.bitkit.services.PaykitReadLane import to.bitkit.services.PaykitSdkOperationLock.Priority @@ -146,9 +147,10 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(it.asset, record.terms?.amount?.asset) assertEquals(PaymentConversion.Fixed(it.rates), record.terms?.conversion) assertTrue(request.acceptsPaymentAmount(it.expected)) - assertFalse(request.acceptsPaymentAmount(it.expected + 1uL)) - assertTrue(request.acceptsLightningInvoiceAmountMsats(it.expected * 1000uL)) - assertFalse(request.acceptsLightningInvoiceAmountMsats(it.expected * 1000uL + 1uL)) + assertFalse(request.acceptsPaymentAmount(it.expected.safe() + 1uL.safe())) + val expectedMsats = it.expected.safe() * 1000uL.safe() + assertTrue(request.acceptsLightningInvoiceAmountMsats(expectedMsats)) + assertFalse(request.acceptsLightningInvoiceAmountMsats(expectedMsats.safe() + 1uL.safe())) } } diff --git a/docs/paykit-issuer-interoperability.md b/docs/paykit-issuer-interoperability.md index 251bd510ae..ab8dc27fd4 100644 --- a/docs/paykit-issuer-interoperability.md +++ b/docs/paykit-issuer-interoperability.md @@ -31,6 +31,11 @@ are excluded. Amounts and rates use positive decimal strings without signs or ex Bitkit accepts at most 80 characters and 38 significant digits per operand and rejects unrepresentable arithmetic instead of approximating it. +For cross-platform support, the exact amount-times-rate product must also fit within +38 significant digits after trailing zeros are removed. Android rejects larger products; +iOS can accept some larger products when Foundation Decimal represents them exactly. +Issuers must not rely on that extra iOS range for Android recipients. + On-chain amounts round upward once to satoshis. Lightning amounts round upward once to millisatoshis and must then be exactly representable in whole satoshis. The existing send sheet uses one amount across its available rails: requests with differing BTC diff --git a/journeys/payment-requests/fixed-price-bitcoin.xml b/journeys/payment-requests/fixed-price-bitcoin.xml index e232904524..06d3270269 100644 --- a/journeys/payment-requests/fixed-price-bitcoin.xml +++ b/journeys/payment-requests/fixed-price-bitcoin.xml @@ -1,4 +1,4 @@ - + Verifies fixed-price requests use the issuer's BTC quote while fiat display uses Bitkit's own exchange rate. Requires the linked fixture issuer and a regtest wallet funded for at least 100,000 sats plus fees, as described in README.md. This journey stops before broadcasting. From 36c92946a2191147272d9488290fa6fa95c85f14 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 8 Oct 2026 08:19:12 -0500 Subject: [PATCH 4/5] fix: stop preparing unpayable payment requests --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 20 ++++-- .../viewmodels/AppViewModelSendFlowTest.kt | 70 +++++++++++++++++++ changelog.d/next/1437.fixed.md | 2 +- journeys/payment-requests/README.md | 4 ++ .../payment-requests/unpayable-endpoint.xml | 16 +++++ 5 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 journeys/payment-requests/unpayable-endpoint.xml diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index ce1f7ef551..18660e0ca6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1322,6 +1322,11 @@ class AppViewModel @Inject constructor( if (result !is PublicPaykitPaymentResult.Opened) { if (result == PublicPaykitPaymentResult.PrivateLinkPending) { finishPrivateLinkPendingPaymentRequestPresentation(request) + } else if (result == PublicPaykitPaymentResult.NotOpened) { + finishUnavailablePaymentRequestPresentation( + request, + IncomingPaykitPaymentRequestFailureReason.EndpointNotPayable, + ) } else { deferPaymentRequestPresentation( request = request, @@ -1448,20 +1453,23 @@ class AppViewModel @Inject constructor( if (restorePaymentRequestSheet && currentSheet.value == null) showSheet(Sheet.PaymentRequests) } - private fun finishUnavailablePaymentRequestPresentation(request: PaykitPaymentRequest) { - paykitPaymentRequestDiagnostics.logPresentationRejection( - request.counterparty, - IncomingPaykitPaymentRequestFailureReason.ResolutionFailed, - ) + private fun finishUnavailablePaymentRequestPresentation( + request: PaykitPaymentRequest, + reason: IncomingPaykitPaymentRequestFailureReason = IncomingPaykitPaymentRequestFailureReason.ResolutionFailed, + ) { + paykitPaymentRequestDiagnostics.logPresentationRejection(request.counterparty, reason) + val endpointNotPayable = reason == IncomingPaykitPaymentRequestFailureReason.EndpointNotPayable val restorePaymentRequestSheet = requestedPaymentRequestId.value == request.id && shouldRestorePaymentRequestSheet - val showUnavailableToast = requestedPaymentRequestId.value == request.id + val showUnavailableToast = requestedPaymentRequestId.value == request.id || endpointNotPayable + if (endpointNotPayable) dismissedPreparingRequestIds.add(request.id) if (requestedPaymentRequestId.value == request.id) { invalidatePaymentRequestPresentation() clearRequestedPaymentRequest() } clearPaymentRequestPresentationRetry(request.id) if (!showUnavailableToast) return + finishPaymentRequestPreparation(paymentRequestPreparation.value) toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.wallet__payment_request), diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index f9200c346b..900bbb6a60 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -3235,6 +3235,76 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertTrue(sut.sendUiState.value.isPaymentRequest) } + @Test + fun `unpayable request closes preparation with feedback and allows manual retry`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + whenever(privatePaykitRepo.beginPaymentRequest(request)) + .thenReturn(Result.success(PublicPaykitPaymentResult.NotOpened)) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) + + sut.startPaykitPaymentRequestPolling() + try { + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + + assertNull(sut.currentSheet.value) + assertNull(activeContactPaymentContext()) + verify(privatePaykitRepo).beginPaymentRequest(request) + val toastCaptor = argumentCaptor() + verify(toastManager).enqueue(toastCaptor.capture()) + assertEquals("PaymentRequestUnavailableToast", toastCaptor.lastValue.testTag) + assertEquals(Toast.ToastType.ERROR, toastCaptor.lastValue.type) + verify(paykitPaymentRequestRepo, never()).markPresented(request) + + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + + assertEquals(Sheet.PaymentRequests, sut.currentSheet.value) + assertNull(sut.requestedPaymentRequestId.value) + verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) + verify(toastManager, times(2)).enqueue(toastCaptor.capture()) + assertEquals("PaymentRequestUnavailableToast", toastCaptor.lastValue.testTag) + } finally { + sut.stopPaykitPaymentRequestPolling() + } + } + + @Test + fun `unpayable request does not block a later payable request`() = test { + sut.setIsAuthenticated(true) + val unavailableRequest = paymentRequest() + val payableRequest = unavailableRequest.copy(paymentRequestId = "payable-request") + val bolt11 = "lnbcrt1payablerequest" + whenever(privatePaykitRepo.beginPaymentRequest(unavailableRequest)) + .thenReturn(Result.success(PublicPaykitPaymentResult.NotOpened)) + stubOpenedPaymentRequest(payableRequest, bolt11) + stubLightningScan(bolt11 = bolt11, amountSats = 0u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + pendingPaykitPaymentRequests.value = listOf(unavailableRequest, payableRequest) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + whenever(paykitPaymentRequestRepo.refresh(any())).thenReturn(Result.success(Unit)) + + sut.startPaykitPaymentRequestPolling() + try { + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + + verify(privatePaykitRepo).beginPaymentRequest(unavailableRequest) + verify(privatePaykitRepo).beginPaymentRequest(payableRequest) + assertEquals(payableRequest, activeContactPaymentContext()?.incomingPaymentRequest) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } finally { + sut.stopPaykitPaymentRequestPolling() + } + } + @Test fun `closing unavailable request preparation admits a later payable request`() = test { sut.setIsAuthenticated(true) diff --git a/changelog.d/next/1437.fixed.md b/changelog.d/next/1437.fixed.md index 254226a13f..447c8d01a8 100644 --- a/changelog.d/next/1437.fixed.md +++ b/changelog.d/next/1437.fixed.md @@ -1 +1 @@ -Pay fixed-price Paykit requests in Bitcoin using the issuer's quoted amount. +Pay fixed-price Paykit requests in Bitcoin using the issuer's quoted amount and stop preparation with feedback when the destination is not payable. diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 407f614b39..8d3c577304 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -142,6 +142,10 @@ must explain that its payment details are unsupported and offer no Subscribe con Incoming preparation uses the existing Send confirmation sheet with saved sender, amount and note. Its payment control stays disabled and loading until fresh resolution and wallet validation finish. +An endpoint rejected as `endpoint_not_payable`, including an already-paid one-time address, +ends preparation immediately with `PaymentRequestUnavailableToast`. The request stays pending +for manual retry, but does not automatically reopen during the current identity's app session. +`unpayable-endpoint.xml` checks this using an address already recorded as paid by the payer. Closing during preparation leaves the request pending and suppresses automatic reopening for the current identity's app session; Pay from the request list or details explicitly retries it. An unfunded wallet can verify loading followed by native rejection, not an enabled payment control. diff --git a/journeys/payment-requests/unpayable-endpoint.xml b/journeys/payment-requests/unpayable-endpoint.xml new file mode 100644 index 0000000000..8e25e60b5a --- /dev/null +++ b/journeys/payment-requests/unpayable-endpoint.xml @@ -0,0 +1,16 @@ + + + Requires a linked fixture issuer and a one-time on-chain address already recorded as paid by this payer. Checks rejection feedback without sending another payment. + + + Launch the E2E Bitkit app with Paykit UI enabled and the fixture issuer saved as a contact and linked as identities + Have the issuer send a new one-time BTC request with a future expiration and its bound on-chain endpoint set to the address already paid by this payer + Wait for incoming preparation to finish; verify the loading confirmation closes and the unavailable-payment toast (id "PaymentRequestUnavailableToast") appears + Open the pending-request bell (id "PaymentRequestsBell") and verify the unpaid request remains listed + Tap Pay for that request and wait for preparation to finish + Verify the unavailable-payment toast (id "PaymentRequestUnavailableToast") appears and the request list or details return without a loading confirmation + Have the issuer send a different request bound to a fresh unused address + Verify the new request reaches confirmation rather than waiting behind the rejected request + Dismiss the confirmation without paying and verify no new payment was created + + From dc54e6ec040aa14989205ccb15c2b434238ca6b3 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 8 Oct 2026 08:46:07 -0500 Subject: [PATCH 5/5] fix: bound fixed-price decimal precision --- .../PaykitBitcoinRequestPricing.kt | 5 ++-- .../PaykitPaymentRequestRepoTest.kt | 28 +++++++++++++++++++ docs/paykit-issuer-interoperability.md | 21 ++++++++------ docs/payment-requests.md | 15 ++++++---- journeys/payment-requests/README.md | 12 ++++++++ .../payment-requests/fixed-price-bitcoin.xml | 2 +- .../payment-requests/unpayable-endpoint.xml | 2 +- 7 files changed, 67 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitBitcoinRequestPricing.kt b/app/src/main/java/to/bitkit/repositories/PaykitBitcoinRequestPricing.kt index 7251cc5146..52a67c7b97 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitBitcoinRequestPricing.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitBitcoinRequestPricing.kt @@ -6,7 +6,7 @@ import java.math.BigDecimal import java.math.RoundingMode internal object PaykitBitcoinRequestPricing { - /** Maximum precision of decimal inputs and normalized products. */ + /** Maximum input precision and combined normalized operand precision. */ private const val MAX_SIGNIFICANT_DIGITS = 38 /** Maximum length of an amount or rate before decimal parsing. */ @@ -39,8 +39,9 @@ internal object PaykitBitcoinRequestPricing { } private fun sats(amount: BigDecimal, rate: BigDecimal, lightning: Boolean): ULong? { + val combinedPrecision = amount.stripTrailingZeros().precision() + rate.stripTrailingZeros().precision() + if (combinedPrecision > MAX_SIGNIFICANT_DIGITS) return null val bitcoin = amount.multiply(rate).stripTrailingZeros() - if (bitcoin.precision() > MAX_SIGNIFICANT_DIGITS) return null val units = bitcoin.movePointRight(if (lightning) MILLISATOSHI_SCALE else 8).setScale(0, RoundingMode.CEILING) val integer = units.toPlainString().toULongOrNull()?.takeIf { it > 0uL } ?: return null if (lightning && integer % 1000uL != 0uL) return null diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 34698bb2ab..cb40b5cf41 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -154,6 +154,34 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @Test + fun `fixed pricing rejects products beyond exact decimal precision`() { + val cases = listOf( + Triple("9." + "0".repeat(35) + "1", "0.00004", 36_001uL), + Triple("9." + "0".repeat(36) + "1", "0.00004", null), + Triple("9." + "0".repeat(36) + "1", "1", null), + Triple("0009." + "0".repeat(35) + "10", "0.0000400", 36_001uL), + ) + cases.forEach { (amount, rate, expected) -> + val record = paymentRequestRecord( + amount = amount, + asset = "usd", + conversion = PaymentConversion.Fixed(listOf(ConversionRate("btc", rate))), + endpoints = listOf("btc-regtest-p2wpkh"), + ) + val result = record.parseIncomingPaykitPaymentRequest(clock.now()) + if (expected != null) { + assertEquals(expected, (result as PaykitPaymentRequestParseResult.Parsed).request.amountSats) + } else { + assertEquals( + PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.UnsupportedPricing), + result, + "$amount * $rate", + ) + } + } + } + @Test fun `fixed pricing omits unquoted cross asset rails`() { val record = paymentRequestRecord( diff --git a/docs/paykit-issuer-interoperability.md b/docs/paykit-issuer-interoperability.md index ab8dc27fd4..ecbb5a6796 100644 --- a/docs/paykit-issuer-interoperability.md +++ b/docs/paykit-issuer-interoperability.md @@ -1,6 +1,6 @@ # Paykit issuer interoperability -This is the Bitkit issuer contract for one-time Paykit Payment Requests. It describes the request and payment-endpoint shapes an issuer must provide for Bitkit to present and open a request. The canonical accepted and rejected examples are in +This is the Bitkit issuer contract for one-time Paykit Payment Requests. It describes the request and payment-endpoint shapes an issuer must provide for Bitkit to present and open a request. Accepted and rejected unpriced request and endpoint-shape examples are in [`app/src/test/resources/paykit-issuer-interoperability.json`](../app/src/test/resources/paykit-issuer-interoperability.json). This contract records Bitkit behavior. Paykit protocol or SDK policy remains owned by Paykit. @@ -31,10 +31,11 @@ are excluded. Amounts and rates use positive decimal strings without signs or ex Bitkit accepts at most 80 characters and 38 significant digits per operand and rejects unrepresentable arithmetic instead of approximating it. -For cross-platform support, the exact amount-times-rate product must also fit within -38 significant digits after trailing zeros are removed. Android rejects larger products; -iOS can accept some larger products when Foundation Decimal represents them exactly. -Issuers must not rely on that extra iOS range for Android recipients. +Before multiplication, both platforms require the sum of the amount's and rate's +normalized significant-digit counts to be at most 38. These counts exclude the decimal +point and leading and trailing zeros. A parity rate of `1` counts as one digit. +This conservative bound keeps the product exact before rounding to payment units; +quotes outside the bound are rejected even when a particular product could fit. On-chain amounts round upward once to satoshis. Lightning amounts round upward once to millisatoshis and must then be exactly representable in whole satoshis. The existing @@ -100,11 +101,11 @@ After this shape check, Bitkit validates that the value is usable: an on-chain a ## Delivery prerequisites -The issuer and wallet identities must be linked Paykit peers before Bitkit polls the request. Requests may require a specific payment App; Bitkit uses the SDK's request-specific resolver and preserves the selected App in its proof. The issuer can include exact `paymentEndpoints` in the Payment Request or supply current endpoints resolved for the requested App. At least one endpoint must be usable for an identifier retained from the request; embedded endpoints do not require separate advertisement. A request that fails the request gate is not presented; a request whose endpoint cannot be resolved is deferred until usable payment details arrive. +The issuer and wallet identities must be linked Paykit peers before Bitkit polls the request. Requests may require a specific payment App; Bitkit uses the SDK's request-specific resolver and preserves the selected App in its proof. The issuer can include exact `paymentEndpoints` in the Payment Request or supply current endpoints resolved for the requested App. At least one endpoint must be usable for an identifier retained from the request; embedded endpoints do not require separate advertisement. A request that fails the request gate is not presented. Missing payment details can be retried; a rejected endpoint ends preparation with unavailable-payment feedback and leaves the request pending for manual retry. ## Contract fixtures -The fixture file is the cross-platform source of truth for Bitkit iOS and Android: +The shared JSON fixture covers unpriced requests and endpoint shapes on both platforms: - Request fixtures cover every documented P2TR, P2WPKH, P2SH, and P2PKH identifier for Bitcoin, testnet, signet, and regtest. - Request fixtures cover both Lightning identifiers on every network. @@ -112,4 +113,8 @@ The fixture file is the cross-platform source of truth for Bitkit iOS and Androi - Endpoint fixtures accept JSON object payloads with a non-empty string `value`, including optional string bounds and surrounding whitespace. - Rejected endpoint fixtures cover a raw string, empty payload, missing/empty/whitespace/numeric `value`, non-string bounds, a wrong-network on-chain identifier, top-level array, malformed JSON, and unsupported identifier. -The iOS fixture suite in [PR #719](https://github.com/synonymdev/bitkit-ios/pull/719) consumes the same fixture names, inputs, and expected results. Any intentional platform difference requires changing this contract and both fixture suites together. +Fixed pricing is covered by matching tables in `PaykitPaymentRequestServiceTests.swift` +on iOS and `PaykitPaymentRequestRepoTest.kt` on Android, not by the JSON fixture. +Those tables cover rate precedence, parity, unit rounding, unsupported quotes and the +combined precision boundary. Changes to this contract must update the relevant tests +on both platforms. diff --git a/docs/payment-requests.md b/docs/payment-requests.md index ca0192266d..40c2939969 100644 --- a/docs/payment-requests.md +++ b/docs/payment-requests.md @@ -10,18 +10,21 @@ parse successfully but cannot be opened. endpoint payload. - Open-time rejection emits a warning with category `resolution` or `presentation`, a stable reason, and only the redacted counterparty. -- An explicit Pay action tries immediately and fourteen more times at two-second intervals. After - the fifteenth failure, Bitkit shows a localized error and leaves the request available for - another attempt. +- An `endpoint_not_payable` rejection ends preparation immediately with + `PaymentRequestUnavailableToast`. The request remains pending for manual retry but does not + automatically reopen during the current identity's app session. +- For retryable resolution failures, an explicit Pay action tries immediately and fourteen more + times at two-second intervals. After the fifteenth failure, Bitkit shows a localized error and + leaves the request available for another attempt. - If the request expires during an explicit presentation attempt, Bitkit logs `category=presentation reason=request_expired` and shows `PaymentRequestExpiredToast` with the localized `wallet__payment_request_expired` message exactly once. -- Automatic presentation uses the same initial retries, then continues every 120 seconds without - showing terminal feedback. +- For retryable resolution failures, automatic presentation uses the same initial retries, then + continues every 120 seconds without showing terminal feedback. The parse reasons are `missing_local_role`, `outgoing_request`, `unsupported_local_role`, `non_actionable_state`, `missing_terms`, `recurring_request`, `unsupported_recurrence`, -`unsupported_payment_deadline`, `unsupported_asset`, `invalid_amount`, `amount_out_of_range`, `no_supported_endpoint`, +`unsupported_payment_deadline`, `unsupported_asset`, `unsupported_pricing`, `invalid_amount`, `amount_out_of_range`, `no_supported_endpoint`, `invalid_expiration`, and `expired`. The resolution reasons are `no_supported_endpoint`, `endpoint_not_payable`, diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 8d3c577304..ce0fed8cd7 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -13,6 +13,18 @@ fractional-satoshi Lightning payments, and dynamic quotes remain unsupported. Run Bitkit against regtest with Paykit UI enabled. Authenticate a Pubky identity, save and link the fixture issuer as a contact, and give the wallet enough on-chain balance to pay 100,000 sats. The fixture issuer must be able to publish a Paykit endpoint and send a one-time Payment Request to that linked peer. Its App ID is `paykit-server`; Bitkit uses `bitkit`. +`fixed-price-bitcoin.xml` additionally requires an issuer built with Paykit rc71 or newer +that can send `conversion.fixed` terms. The basic BTC request fixture and Bitkit's Request UI +cannot create these quotes. Use the standalone `tools/paykit-fixture-sender` documented in +[bitkit-e2e-tests #269](https://github.com/synonymdev/bitkit-e2e-tests/pull/269), or an equivalent +conversion-capable issuer, and follow its setup and linking instructions. + +`unpayable-endpoint.xml` requires an issuer that can bind a new request to a one-time address +already recorded as paid by this payer, then send another request with a fresh unused address. +Prepare that paid-address fixture separately; the journey itself sends no additional payment. +If the required issuer or paid-address state is unavailable, report the corresponding journey +as blocked, not as an app failure or a pass. + The accepted journey uses: - Payment Request ID: `71300000-0000-4000-8000-000000000001` diff --git a/journeys/payment-requests/fixed-price-bitcoin.xml b/journeys/payment-requests/fixed-price-bitcoin.xml index 06d3270269..aa6af658bb 100644 --- a/journeys/payment-requests/fixed-price-bitcoin.xml +++ b/journeys/payment-requests/fixed-price-bitcoin.xml @@ -1,6 +1,6 @@ - Verifies fixed-price requests use the issuer's BTC quote while fiat display uses Bitkit's own exchange rate. Requires the linked fixture issuer and a regtest wallet funded for at least 100,000 sats plus fees, as described in README.md. This journey stops before broadcasting. + Verifies fixed-price requests use the issuer's BTC quote while fiat display uses Bitkit's own exchange rate. Requires a linked Paykit rc71-or-newer issuer capable of sending conversion.fixed terms and a regtest wallet funded for at least 100,000 sats plus fees, as described in README.md. The basic BTC fixture is insufficient; report this journey as blocked if the conversion-capable issuer is unavailable. This journey stops before broadcasting. Launch the E2E Bitkit app with Paykit UI enabled and the fixture issuer saved as a contact and linked as identities diff --git a/journeys/payment-requests/unpayable-endpoint.xml b/journeys/payment-requests/unpayable-endpoint.xml index 8e25e60b5a..50bfd12f21 100644 --- a/journeys/payment-requests/unpayable-endpoint.xml +++ b/journeys/payment-requests/unpayable-endpoint.xml @@ -1,6 +1,6 @@ - Requires a linked fixture issuer and a one-time on-chain address already recorded as paid by this payer. Checks rejection feedback without sending another payment. + Requires a linked fixture issuer that can bind a new request to a one-time on-chain address already recorded as paid by this payer, then issue a request for a fresh address. See README.md for setup; report this journey as blocked if that fixture is unavailable. Checks rejection feedback without sending another payment. Launch the E2E Bitkit app with Paykit UI enabled and the fixture issuer saved as a contact and linked as identities