From b43bc17f8e990ea9364a48b0aac8d4848a704458 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 6 Nov 2026 11:42:08 -0500 Subject: [PATCH 01/13] chore: update paykit to 0.1.1-rc6 Co-Authored-By: Claude Opus 5.5 --- .../to/bitkit/repositories/PaykitIssuerInteropTest.kt | 2 ++ .../to/bitkit/repositories/PaykitPaymentProofRepoTest.kt | 2 ++ .../PaykitPaymentRequestRepoSubscriptionTest.kt | 2 ++ .../bitkit/repositories/PaykitPaymentRequestRepoTest.kt | 2 ++ .../to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt | 7 ++++++- .../java/to/bitkit/repositories/PrivatePaykitRepoTest.kt | 2 ++ .../test/java/to/bitkit/services/PaykitSdkServiceTest.kt | 9 +++++---- gradle/libs.versions.toml | 2 +- 8 files changed, 22 insertions(+), 6 deletions(-) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt index a5febcda89..3c3ff2d45a 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt @@ -149,6 +149,8 @@ class PaykitIssuerInteropTest { proposalAppId = "bitkit", payerAppId = null, executionClaimAppId = null, + proposalRecordedAt = null, + acceptanceRecordedAt = null, ) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index c6f146bb78..57f8d97426 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -3666,5 +3666,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { proposalAppId = "bitkit", payerAppId = null, executionClaimAppId = null, + proposalRecordedAt = null, + acceptanceRecordedAt = null, ) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index e51e73d959..1a8ce42b85 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -1669,6 +1669,8 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat proposalAppId = "bitkit", payerAppId = null, executionClaimAppId = null, + proposalRecordedAt = null, + acceptanceRecordedAt = null, ) private fun linkedPeer( publicKey: String, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 148d9dc9ae..029cc864ce 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -3664,6 +3664,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { proposalAppId = "bitkit", payerAppId = null, executionClaimAppId = null, + proposalRecordedAt = null, + acceptanceRecordedAt = null, ) private fun linkedPeer( diff --git a/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt index e4f2795f08..26717af62d 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt @@ -785,7 +785,10 @@ class PaykitUsdtPaymentRepoTest : BaseUnitTest() { assertEquals(true, repo.receipts.value.single().satisfied) } - private fun receivedRequest(id: String = request.paymentRequestId): PaymentRequestRecord { + private fun receivedRequest( + id: String = request.paymentRequestId, + proposalRecordedAt: String? = null, + ): PaymentRequestRecord { val json = Json.encodeToString( PaykitUsdtProof(UsdtPaymentProof("42161", "0x" + "a".repeat(64), "0", "0x" + "b".repeat(130))) ) @@ -806,6 +809,8 @@ class PaykitUsdtPaymentRepoTest : BaseUnitTest() { proposalAppId = "bitkit", payerAppId = "bitkit", executionClaimAppId = "bitkit", + proposalRecordedAt = proposalRecordedAt, + acceptanceRecordedAt = null, terms = PaymentRequestTerms( PaymentRequestAmount("0.05", "usd"), reference, diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index fb17b7088c..d1377085d3 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -4897,6 +4897,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { }, failedToQueue = failedToQueue, failedToDeliver = failedToDeliver, + acknowledged = emptyList(), ) private fun cachedPublishedContact() = PrivatePaykitContactCacheData( @@ -4914,6 +4915,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { .map { privateListSyncChange(it.counterparty) }, failedToQueue = emptyList(), failedToDeliver = emptyList(), + acknowledged = emptyList(), ) private fun privateListSyncChange( diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index cee385b416..62df6dac55 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -226,7 +226,7 @@ class PaykitSdkServiceTest { } "revision" } - val report = PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList()) + val report = PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()) whenever(sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound(any(), any())).thenReturn(report) val updates = listOf(PrivatePaymentListReservationUpdateInput(RING_PUBKY, listOf(mock()))) val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } @@ -2519,7 +2519,7 @@ class PaykitSdkServiceTest { withdrawal.thenThrow(IllegalStateException("network unavailable")) } else { withdrawal.thenReturn( - PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList()), + PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()), ) } val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } @@ -3355,7 +3355,7 @@ class PaykitSdkServiceTest { val sdk = mock() val started = CompletableDeferred() val resume = CompletableDeferred() - val report = PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList()) + val report = PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()) whenever(sdk.linkedPeers()).thenReturn(listOf(contactPeer(LinkedPeerState.LINKED))) whenever(sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound(any(), any())).doSuspendableAnswer { started.complete(Unit) @@ -3415,7 +3415,7 @@ class PaykitSdkServiceTest { whenever(sdk.paykitAppRegistry(RING_PUBKY)).thenReturn( PaykitAppRegistry(1u, null, listOf(PaykitApp("bitkit", "Bitkit", capabilities)), null, emptyMap()), ) - val report = PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList()) + val report = PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()) val updates = listOf(RING_PUBKY, other).map { PrivatePaymentListReservationUpdateInput(it, emptyList()) } whenever(sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound(updates, false)).thenReturn(report) val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } @@ -3462,6 +3462,7 @@ class PaykitSdkServiceTest { emptyList() }, failedToDeliver = emptyList(), + acknowledged = emptyList(), ) } val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b860617b5c..e24fa2969c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.8.0" } 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" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.1-rc2" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.1-rc6" } bouncycastle-pkix-jdk = { module = "org.bouncycastle:bcpkix-jdk18on", version = "1.86" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.86" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } From 048b4a33ef075a125cfbfdaf7729f97fe4e8eea7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 6 Nov 2026 12:04:18 -0500 Subject: [PATCH 02/13] fix: measure paykit proofs with the sdk before paying Co-Authored-By: Claude Opus 5.5 --- .../repositories/PaykitPaymentProofRepo.kt | 65 +++++++++++++++++++ .../repositories/PaykitUsdtPaymentRepo.kt | 48 ++++---------- .../to/bitkit/services/PaykitSdkService.kt | 26 ++++++++ .../PaykitPaymentProofRepoTest.kt | 41 +++++++++++- .../repositories/PaykitUsdtPaymentRepoTest.kt | 52 ++++++++++----- 5 files changed, 177 insertions(+), 55 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index a07a0520e6..7c5d3450cb 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -1,5 +1,6 @@ package to.bitkit.repositories +import com.synonym.bitkitcore.UsdtPaymentProof import com.synonym.paykit.BillingPeriod import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentRequestLifecycleState @@ -41,6 +42,7 @@ import to.bitkit.ext.toHex import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.PaykitPaymentStateBackup import to.bitkit.models.PaykitRequestPayment +import to.bitkit.models.PaykitUsdt import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.WalletScope import to.bitkit.services.PaykitSdkOperationLock @@ -203,6 +205,15 @@ class PaykitPaymentProofRepo @Inject constructor( ) { throw PaykitPaymentRequestError.RequestUnavailable } + paykitSdkService.requirePaymentProofFits( + paymentReference = request.paymentReference, + paymentAppId = paymentAppId, + paymentEndpointIdentifier = paymentEndpointIdentifier, + kind = kind, + billingPeriod = request.billingPeriod, + conversionQuoteId = paymentTerms?.quoteId, + allowanceId = allowanceId, + ) val read = paykitSdkService.readPaymentRequests(request.counterparty) val identityStatus = read.identityStatus val identity = identityStatus?.publicKey?.let(PubkyPublicKeyFormat::normalized) @@ -1684,6 +1695,60 @@ private fun proofJson(kind: PaykitPaymentProofKind, data: String): String { }.toString() } +/** Hex length of the 32-byte preimage or transaction id a Bitcoin proof carries. */ +private const val BITCOIN_PROOF_DATA_LENGTH = 64 + +/** Hex digits of a 32-byte ERC-20 transaction hash after its `0x` prefix. */ +private const val USDT_TRANSACTION_HASH_DIGITS = 64 + +/** Hex digits of a 65-byte EIP-712 signature after its `0x` prefix. */ +private const val USDT_SIGNATURE_DIGITS = 130 + +/** Decimal digits of the largest uint256 receipt log index. */ +private const val USDT_LOG_INDEX_DIGITS = 78 + +/** The longest proof Bitkit submits for this kind, so a measured proof is never shorter than the one sent. */ +internal fun PaykitPaymentProofKind.longestProofJson(): String = when (this) { + PaykitPaymentProofKind.Usdt -> Json.encodeToString( + PaykitUsdtProof.serializer(), + PaykitUsdtProof( + UsdtPaymentProof( + chainId = PaykitUsdt.CHAIN_ID, + transactionHash = "0x" + "0".repeat(USDT_TRANSACTION_HASH_DIGITS), + receiptLogIndex = "9".repeat(USDT_LOG_INDEX_DIGITS), + signature = "0x" + "0".repeat(USDT_SIGNATURE_DIGITS), + ), + ), + ) + PaykitPaymentProofKind.Lightning, PaykitPaymentProofKind.Onchain -> + proofJson(this, "0".repeat(BITCOIN_PROOF_DATA_LENGTH)) +} + +/** + * Fails with [PaykitPaymentRequestError.RequestUnavailable] when the longest proof of [kind] for these values would + * not fit one Private Application Message, so nothing is paid for a proof that could never be delivered. + */ +internal fun PaykitSdkService.requirePaymentProofFits( + paymentReference: String, + paymentAppId: String, + paymentEndpointIdentifier: String, + kind: PaykitPaymentProofKind, + billingPeriod: PaykitBillingPeriod?, + conversionQuoteId: String?, + allowanceId: String?, +) { + val length = paymentProofEventLength( + paymentReference = paymentReference, + paymentAppId = paymentAppId, + paymentEndpointIdentifier = paymentEndpointIdentifier, + proofJson = kind.longestProofJson(), + billingPeriod = billingPeriod, + conversionQuoteId = conversionQuoteId, + allowanceId = allowanceId, + ) + if (length > PaykitSdkService.MAX_MESSAGE_BYTES.toUInt()) throw PaykitPaymentRequestError.RequestUnavailable +} + private fun String.proofValues(): JsonObject? = runCatching { Json.parseToJsonElement(this).jsonObject }.getOrNull() private fun String.matchesPaymentHash(paymentHash: String): Boolean { diff --git a/app/src/main/java/to/bitkit/repositories/PaykitUsdtPaymentRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitUsdtPaymentRepo.kt index 2d9e01aaea..19b2b9affa 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitUsdtPaymentRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitUsdtPaymentRepo.kt @@ -27,9 +27,6 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher import to.bitkit.ext.runSuspendCatching @@ -138,39 +135,6 @@ class PaykitUsdtPaymentRepo @Inject constructor( conversionQuoteId = quoteId.orEmpty(), ) } - - fun validateProofSize(binding: UsdtPaymentProofBinding) { - val profile = - PaykitUsdtProof( - UsdtPaymentProof("42161", "0x" + "0".repeat(64), "9".repeat(78), "0x" + "0".repeat(130)) - ) - val message = buildJsonObject { - put("version", 1) - put("kind", "paykit.payment_proof") - put("app_id", "bitkit") - put("event_id", UUID.randomUUID().toString()) - put("payment_request_id", binding.paymentRequestId) - put("payment_reference", binding.paymentReference) - put("payment_app_id", binding.paymentAppId) - put("payment_endpoint_identifier", binding.paymentEndpointIdentifier) - put( - "billing_period", - if (binding.periodStartsAt.isEmpty()) { - JsonNull - } else { - buildJsonObject { - put("starts_at", binding.periodStartsAt) - put("ends_at", binding.periodEndsAt) - } - } - ) - if (binding.conversionQuoteId.isNotEmpty()) put("conversion_quote_id", binding.conversionQuoteId) - put("proof", Json.encodeToJsonElement(PaykitUsdtProof.serializer(), profile)) - } - if (message.toString().toByteArray(Charsets.UTF_8).size > PaykitSdkService.MAX_MESSAGE_BYTES) { - throw PaykitPaymentRequestError.RequestUnavailable - } - } } @Serializable @@ -433,7 +397,17 @@ class PaykitUsdtPaymentRepo @Inject constructor( it.billingPeriod?.sdkValue ) } - binding?.let(::validateProofSize) + binding?.let { + sdk.requirePaymentProofFits( + paymentReference = it.paymentReference, + paymentAppId = it.paymentAppId, + paymentEndpointIdentifier = it.paymentEndpointIdentifier, + kind = PaykitPaymentProofKind.Usdt, + billingPeriod = request.billingPeriod, + conversionQuoteId = it.conversionQuoteId.takeIf(String::isNotEmpty), + allowanceId = null, + ) + } val attempt = PaykitUsdtAttempt( quote.id, diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index f90d7e2f7c..1301df5fbe 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -83,6 +83,7 @@ import com.synonym.paykit.defaultConfig import com.synonym.paykit.defaultPubkyClientConfig import com.synonym.paykit.parsePubkyAuthUrl import com.synonym.paykit.paykitAuthorizerSessionCapabilities +import com.synonym.paykit.paymentProofEventLen import com.synonym.paykit.pubkyPublicKeyFromSecret import com.synonym.paykit.pubkySecretKeyFromBip39Mnemonic import dagger.hilt.android.qualifiers.ApplicationContext @@ -1297,6 +1298,31 @@ class PaykitSdkService @Inject constructor( } } + /** + * Byte length of the Payment Proof Event Message [submitPaymentProof] would queue for these values. The SDK builds + * it with the code it queues with, so a payer can check it against [MAX_MESSAGE_BYTES] before paying. + */ + fun paymentProofEventLength( + paymentReference: String, + paymentAppId: String, + paymentEndpointIdentifier: String, + proofJson: String, + billingPeriod: PaykitBillingPeriod? = null, + conversionQuoteId: String? = null, + allowanceId: String? = null, + ): UInt = PaymentReference(paymentReference).use { reference -> + PaymentProofSubmission( + billingPeriod = billingPeriod?.sdkValue, + paymentAppId = paymentAppId, + paymentEndpointIdentifier = paymentEndpointIdentifier, + allowanceId = allowanceId, + conversionQuoteId = conversionQuoteId, + proof = PrivateJsonObject(proofJson), + ).use { submission -> + paymentProofEventLen(localAppId = "bitkit", paymentReference = reference, submission = submission) + } + } + suspend fun listAllowances(filter: AllowanceFilter): List { isSetup.await() return operationLock.withLock { diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 57f8d97426..ac0d934ed9 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -48,6 +48,7 @@ import org.lightningdevkit.ldknode.PaymentDirection import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PaymentStatus import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn @@ -156,7 +157,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { emptyList() } PaymentRequestRead(status, records) - }.whenever(paykitSdkService).readPaymentRequests(any(), org.mockito.kotlin.anyOrNull()) + }.whenever(paykitSdkService).readPaymentRequests(any(), anyOrNull()) whenever(store.load()).thenAnswer { if (shouldFailNextLoad) { shouldFailNextLoad = false @@ -253,6 +254,44 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService, never()).processPendingPrivateMessages() } + @Test + fun `lightning proof that fits one message is measured with its largest preimage before payment`() = test { + val request = paymentRequest(MethodId.Bolt11.rawValue) + whenever( + paykitSdkService.paymentProofEventLength(any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull()) + ).thenReturn(PaykitSdkService.MAX_MESSAGE_BYTES.toUInt()) + + paymentProofRepo() + .prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning, allowanceId = "a") + .getOrThrow() + + verify(paykitSdkService).paymentProofEventLength( + paymentReference = request.paymentReference, + paymentAppId = "bitkit", + paymentEndpointIdentifier = MethodId.Bolt11.rawValue, + proofJson = """{"data":"${"0".repeat(64)}","type":"bitcoin-bolt11-preimage"}""", + billingPeriod = request.billingPeriod, + conversionQuoteId = null, + allowanceId = "a", + ) + assertEquals(request.id, storedProofs.single().requestId) + } + + @Test + fun `onchain proof too large for one message fails before payment`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + whenever( + paykitSdkService.paymentProofEventLength(any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull()) + ).thenReturn(PaykitSdkService.MAX_MESSAGE_BYTES.toUInt() + 1u) + + val result = paymentProofRepo() + .prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) + + assertEquals(PaykitPaymentRequestError.RequestUnavailable, result.exceptionOrNull()) + assertTrue(storedProofs.isEmpty()) + verify(paykitSdkService, never()).readPaymentRequests(any(), anyOrNull()) + } + @Test fun `proof snapshot waits for local persistence to finish`() = test { val request = paymentRequest(MethodId.Bolt11.rawValue) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt index 26717af62d..07ede44583 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt @@ -740,29 +740,47 @@ class PaykitUsdtPaymentRepoTest : BaseUnitTest() { } @Test - fun `proof envelope includes the application id at the UTF-8 message boundary`() { - val binding = PaykitUsdtPaymentRepo.binding(request, identity, "bitkit", null, null) - // The rc63 envelope and largest ERC-20 receipt proof reserve 692 bytes before the reference. - val exactLimit = binding.copy(paymentReference = "é".repeat(154)) - PaykitUsdtPaymentRepo.validateProofSize(exactLimit) - assertFailsWith { - PaykitUsdtPaymentRepo.validateProofSize( - exactLimit.copy(paymentReference = exactLimit.paymentReference + "x") - ) - } + fun `proof that fits one message is measured by the sdk before the payment is prepared`() = test { + whenever( + sdk.paymentProofEventLength(any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull()) + ).thenReturn(PaykitSdkService.MAX_MESSAGE_BYTES.toUInt()) + + repo().prepare(context(), quote, amount, request.payment(MethodId.UsdtArbitrum, clock.now())).getOrThrow() + + verify(sdk).paymentProofEventLength( + paymentReference = request.paymentReference, + paymentAppId = "bitkit", + paymentEndpointIdentifier = MethodId.UsdtArbitrum.rawValue, + proofJson = PaykitPaymentProofKind.Usdt.longestProofJson(), + billingPeriod = null, + conversionQuoteId = null, + allowanceId = null, + ) + assertTrue(stored != null) } @Test - fun `proof size is bounded before the payment is prepared`() = test { - val binding = PaykitUsdtPaymentRepo.binding(request, identity, "bitkit", null, request.billingPeriod?.sdkValue) - PaykitUsdtPaymentRepo.validateProofSize(binding) - val oversized = context(request.copy(paymentReference = "é".repeat(256))) - assertTrue( - repo().prepare(oversized, quote, amount, request.payment(MethodId.UsdtArbitrum, clock.now())).isFailure - ) + fun `proof too large for one message fails before the payment is prepared`() = test { + whenever( + sdk.paymentProofEventLength(any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull()) + ).thenReturn(PaykitSdkService.MAX_MESSAGE_BYTES.toUInt() + 1u) + + val result = repo().prepare(context(), quote, amount, request.payment(MethodId.UsdtArbitrum, clock.now())) + + assertEquals(PaykitPaymentRequestError.RequestUnavailable, result.exceptionOrNull()) assertEquals(null, stored) } + @Test + fun `longest usdt proof carries the largest receipt log index`() { + val proof = Json.decodeFromString(PaykitPaymentProofKind.Usdt.longestProofJson()) + + assertEquals(PaykitUsdt.CHAIN_ID, proof.chainId) + assertEquals(66, proof.transactionHash.length) + assertEquals("9".repeat(78), proof.receiptLogIndex) + assertEquals(132, proof.signature.length) + } + @Test fun `invalid evidence does not prevent another request from completing`() = test { val records = listOf(receivedRequest(), receivedRequest("550e8400-e29b-41d4-a716-446655440001")) From 68125d3925d3b2d6e95bf10fb18a2d51087550fa Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 6 Nov 2026 12:04:55 -0500 Subject: [PATCH 03/13] fix: flag usdt payments made before their request Co-Authored-By: Claude Opus 5.5 --- .../to/bitkit/models/PaykitUsdtStateBackup.kt | 5 +- .../repositories/PaykitUsdtPaymentRepo.kt | 17 ++++++- .../paymentrequests/PaymentRequestsScreen.kt | 1 + app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-b+es+419/strings.xml | 1 + app/src/main/res/values-ca/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-el/strings.xml | 1 + app/src/main/res/values-es-rES/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../models/PaykitPaymentStateBackupTest.kt | 17 ++++++- .../repositories/PaykitUsdtPaymentRepoTest.kt | 49 +++++++++++++++++++ 21 files changed, 101 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/PaykitUsdtStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitUsdtStateBackup.kt index 03e4a1c3ae..3b9df92bf9 100644 --- a/app/src/main/java/to/bitkit/models/PaykitUsdtStateBackup.kt +++ b/app/src/main/java/to/bitkit/models/PaykitUsdtStateBackup.kt @@ -23,16 +23,17 @@ data class PaykitUsdtStateBackup( val receivedAtMillis: Long, val underpaid: Boolean, val afterExpiry: Boolean, + val beforeRequest: Boolean = false, ) { constructor(value: PaykitUsdtReceipt) : this( value.wallet, value.identity, value.requestId, value.paymentId, value.proofEventId, value.verified, value.transferId, value.amount.atomic, value.receivedAtMillis, - value.underpaid, value.afterExpiry, + value.underpaid, value.afterExpiry, value.beforeRequest, ) fun restored() = PaykitUsdtReceipt( wallet, identity, requestId, paymentId, proofEventId, verified, transferId, - PaykitAmount(PaykitAsset.USDT, amountAtomic), receivedAtMillis, underpaid, afterExpiry, + PaykitAmount(PaykitAsset.USDT, amountAtomic), receivedAtMillis, underpaid, afterExpiry, beforeRequest, ) } } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitUsdtPaymentRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitUsdtPaymentRepo.kt index 19b2b9affa..78b0c46176 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitUsdtPaymentRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitUsdtPaymentRepo.kt @@ -44,6 +44,7 @@ import java.util.UUID import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -92,8 +93,10 @@ data class PaykitUsdtReceipt( val receivedAtMillis: Long, val underpaid: Boolean, val afterExpiry: Boolean, + /** Paid earlier than the payee recorded its own proposal, so it does not count as paying the request. */ + val beforeRequest: Boolean = false, ) { - val satisfied: Boolean get() = verified && !underpaid && !afterExpiry + val satisfied: Boolean get() = verified && !underpaid && !afterExpiry && !beforeRequest fun matches(request: PaykitPaymentRequest): Boolean = requestId == request.id && proofEventId == request.paymentProofEventId @@ -112,6 +115,12 @@ class PaykitUsdtPaymentRepo @Inject constructor( private val KEY = Keychain.Key.PAYKIT_USDT_PAYMENTS.name private const val TAG = "PaykitUsdtPaymentRepo" + /** + * How far an Arbitrum block timestamp may trail the payee's clock. The sequencer may set timestamps up to a + * day in the past, so a transfer counts as earlier than the request only beyond this margin. + */ + internal val PAYMENT_TIME_TOLERANCE = 24.hours + fun binding( request: PaykitPaymentRequest, identity: String, @@ -639,6 +648,11 @@ class PaykitUsdtPaymentRepo @Inject constructor( updated } + private fun PaymentRequestRecord.isPaidBeforeProposal(paidAt: Instant): Boolean { + val proposedAt = proposalRecordedAt?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return false + return paidAt < proposedAt - PAYMENT_TIME_TOLERANCE + } + private fun PaykitUsdtAttempt.bindingState() = copy(proof = null, proofQueued = false, paymentStarted = false) private suspend fun reconcileReceipts(reconciliation: Reconciliation) { @@ -716,6 +730,7 @@ class PaykitUsdtPaymentRepo @Inject constructor( receivedAtMillis = receivedAt.toEpochMilliseconds(), underpaid = amount.atomic < terms.amount.atomic, afterExpiry = !terms.isValid(receivedAt), + beforeRequest = record.isPaidBeforeProposal(receivedAt), ) mutex.withLock { if (!isCurrent(reconciliation)) return@withLock diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index c7f57fc0df..414449d9a0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -821,6 +821,7 @@ internal fun paykitUsdtReceiptText(receipt: PaykitUsdtReceipt): String { val label = when { !receipt.verified -> R.string.wallet__payment_request_pending receipt.underpaid -> R.string.wallet__payment_request_underpaid + receipt.beforeRequest -> R.string.wallet__payment_request_before_request receipt.afterExpiry -> R.string.wallet__payment_request_after_expiry else -> R.string.wallet__payment_request_payment_verified } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 7157df23d3..90942c35f0 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1541,6 +1541,7 @@ قبول الدفع بـ مستلم · بعد انتهاء الصلاحية المبلغ + مستلم · قبل الطلب اختر المستلم تجاهل أدخل pubky 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 62bd0a0f1b..f56a40e03c 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1548,6 +1548,7 @@ Aceptar pago en Recibido · Tras vencer Monto + Recibido · Antes de la solicitud Elegir destinatario Descartar Introduzca un pubky diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index c6f9c388b9..9a549033cb 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1545,6 +1545,7 @@ Acceptar pagament en Rebut · Després del venciment Import + Rebut · Abans de la sol·licitud Triar destinatari Descartar Introdueix un pubky diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index b6bd8d3b17..573f3ece32 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1548,6 +1548,7 @@ Přijmout platbu v Přijato · Po vypršení Částka + Přijato · Před žádostí Vybrat příjemce Odmítnout Zadejte pubky diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b45acef595..26edf14a5e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1547,6 +1547,7 @@ Zahlung akzeptieren in Empfangen · Nach Ablauf Betrag + Empfangen · Vor der Anforderung Empfänger wählen Verwerfen pubky eingeben diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index be27aee396..620941b9eb 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1542,6 +1542,7 @@ Αποδοχή πληρωμής σε Ελήφθη · Μετά τη λήξη Ποσό + Ελήφθη · Πριν από το αίτημα Επιλογή παραλήπτη Απόρριψη Εισαγωγή pubky diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index d51da65431..65ebd51cb8 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1544,6 +1544,7 @@ Aceptar pago en Recibido · Tras caducar Cantidad + Recibido · Antes de la solicitud Elegir destinatario Descartar Introducir pubky diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index c67b788e3c..ea266cdb7c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1544,6 +1544,7 @@ Aceptar pago en Recibido · Tras caducar Cantidad + Recibido · Antes de la solicitud Elegir destinatario Descartar Introducir pubky diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b9d86083d3..3b562b5059 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1545,6 +1545,7 @@ Accepter le paiement en Reçu · Après expiration Montant + Reçu · Avant la demande Choisir le destinataire Contact Date diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1b9544f4db..9affd1b720 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1547,6 +1547,7 @@ Accetta pagamento in Ricevuto · Dopo la scadenza Importo + Ricevuto · Prima della richiesta Scegli Destinatario Ignora Inserisci pubky diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 60745893b4..2e5b17021a 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1547,6 +1547,7 @@ Betaling accepteren in Ontvangen · Na vervaldatum Bedrag + Ontvangen · Vóór het verzoek Ontvanger kiezen Contact Negeren diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 43b6cde23b..eb8c4c4f50 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1547,6 +1547,7 @@ Akceptuj płatność w Otrzymano · Po terminie Kwota + Otrzymano · Przed żądaniem Wybierz odbiorcę Odrzuć Wprowadź pubky diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 1f58c229a5..cc80fa335a 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1548,6 +1548,7 @@ Aceitar pagamento em Recebido · Após o vencimento Valor + Recebido · Antes da solicitação Escolher Destinatário Dispensar Digite a pubky diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index a157ac38fe..1e3a1e04dc 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1542,6 +1542,7 @@ Aceitar pagamento em Recebido · Após expirar Montante + Recebido · Antes do pedido Escolher destinatário Ignorar Introduza o pubky diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 8415b1d10d..2224201bf7 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1562,6 +1562,7 @@ Принимать оплату в Получено · После истечения Сумма + Получено · До запроса Выберите Получателя Отклонить Введите pubky diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 49512cdbbe..59c3d3c4ec 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1415,6 +1415,7 @@ Accept payment in Received · After expiry Amount + Received · Before request Choose Recipient Contact Date diff --git a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt index 57d40557c6..4246506b4d 100644 --- a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt @@ -95,9 +95,24 @@ class PaykitPaymentStateBackupTest { assertEquals("2026-10-01T00:00:00.125Z", attempt.billingPeriod?.sdkValue?.startsAt) assertEquals(50_000uL, receipt.amount.atomic) assertEquals(attempt.requestId, receipt.requestId) + assertFalse(receipt.beforeRequest, "A backup written without the flag restores it as unset") val rebuilt = PaykitUsdtStateBackup(listOf(attempt), listOf(PaykitUsdtStateBackup.Receipt(receipt))) val json = Json { encodeDefaults = true } - assertEquals(json.parseToJsonElement(fixture), json.parseToJsonElement(json.encodeToString(rebuilt))) + val expected = fixture.replace("\"afterExpiry\": false", "\"afterExpiry\": false,\n\"beforeRequest\": false") + assertEquals(json.parseToJsonElement(expected), json.parseToJsonElement(json.encodeToString(rebuilt))) + } + + @Test + fun `usdt backup keeps a receipt paid before its request`() { + val backup = Json.decodeFromString(usdtBackupFixture) + val receipt = backup.receipts.single().restored().copy(beforeRequest = true) + + val restored = Json.decodeFromString( + Json.encodeToString(PaykitUsdtStateBackup(backup.attempts, listOf(PaykitUsdtStateBackup.Receipt(receipt)))), + ).receipts.single().restored() + + assertTrue(restored.beforeRequest) + assertFalse(restored.satisfied) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt index 07ede44583..14a114b0bc 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt @@ -70,6 +70,8 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -688,6 +690,7 @@ class PaykitUsdtPaymentRepoTest : BaseUnitTest() { for (invalidReceipt in listOf( receipt.copy(underpaid = true), receipt.copy(afterExpiry = true), + receipt.copy(beforeRequest = true), receipt.copy(verified = false), receipt.copy(proofEventId = "unrelated") )) { @@ -699,6 +702,52 @@ class PaykitUsdtPaymentRepoTest : BaseUnitTest() { } } + @Test + fun `transfer within the timestamp tolerance of the proposal pays the request`() = test { + val paidAt = clock.now() + for (proposedAt in listOf(paidAt - 1.hours, paidAt + PaykitUsdtPaymentRepo.PAYMENT_TIME_TOLERANCE)) { + val record = receivedRequest(proposalRecordedAt = proposedAt.toString()) + whenever(sdk.paymentRequests()).thenReturn(listOf(record)) + stubVerification(Result.success(verifiedPayment(paidAt))) + stored = null + val repo = repo() + + repo.reconcile().getOrThrow() + + val receipt = repo.receipts.value.single() + assertFalse(receipt.beforeRequest) + assertTrue(receipt.satisfied) + } + } + + @Test + fun `transfer earlier than the proposal beyond the tolerance does not pay the request`() = test { + val paidAt = clock.now() + val proposedAt = paidAt + PaykitUsdtPaymentRepo.PAYMENT_TIME_TOLERANCE + 1.seconds + val record = receivedRequest(proposalRecordedAt = proposedAt.toString()) + whenever(sdk.paymentRequests()).thenReturn(listOf(record)) + stubVerification(Result.success(verifiedPayment(paidAt))) + val repo = repo() + + repo.reconcile().getOrThrow() + + val receipt = repo.receipts.value.single() + assertTrue(receipt.verified) + assertTrue(receipt.beforeRequest) + assertFalse(receipt.satisfied) + assertTrue(repo.verifiedReceipts(identity).getOrThrow().isEmpty()) + assertEquals(receipt, PaykitUsdtStateBackup.Receipt(receipt).restored()) + } + + private fun verifiedPayment(paidAt: Instant) = UsdtVerifiedPayment( + "42161:transaction:0", + "transaction:0", + "sender", + address, + 50_000u, + paidAt.epochSeconds.toULong(), + ) + @Test fun `receipt status survives restart and reflects canonical execution and payment deadline`() = test { val record = receivedRequest().let { From b2ae119c075ee55a62cec8c3bb0286320d1f8441 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 6 Nov 2026 12:05:21 -0500 Subject: [PATCH 04/13] fix: explain payment request notes too long to send Co-Authored-By: Claude Opus 5.5 --- .../java/to/bitkit/ext/PaykitExceptionExt.kt | 6 ++++ .../repositories/PaykitPaymentRequestRepo.kt | 21 ++++++++++++-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 12 +++++++- app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-b+es+419/strings.xml | 1 + app/src/main/res/values-ca/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-el/strings.xml | 1 + app/src/main/res/values-es-rES/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + ...aykitPaymentRequestRepoSubscriptionTest.kt | 23 +++++++++++++++ .../PaykitPaymentRequestRepoTest.kt | 28 +++++++++++++++++++ 21 files changed, 103 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt b/app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt index 42133dc7c5..b8ba0e2c02 100644 --- a/app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt +++ b/app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt @@ -2,6 +2,9 @@ package to.bitkit.ext import com.synonym.paykit.PaykitException +/** Protocol error code for an outbound Private Application Message larger than one pubky-noise message. */ +private const val MESSAGE_TOO_LARGE_CODE = "message_too_large" + fun Throwable.isPaykitIdentityError(): Boolean = generateSequence(this) { it.cause }.any { it is PaykitException.Identity } @@ -13,3 +16,6 @@ fun Throwable.isPaykitTemporarilyUnavailable(): Boolean = it is PaykitException.ConcurrentUpdate || it is PaykitException.SharedStateBusy || it is PaykitException.Transport } + +fun Throwable.isPaykitMessageTooLarge(): Boolean = + generateSequence(this) { it.cause }.any { it is PaykitException.Protocol && it.code == MESSAGE_TOO_LARGE_CODE } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 4e92b203ae..877c5a0438 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -53,6 +53,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher import to.bitkit.di.SubscriptionClock import to.bitkit.env.Env +import to.bitkit.ext.isPaykitMessageTooLarge import to.bitkit.ext.runSuspendCatching import to.bitkit.flags.PaykitFeatureFlags import to.bitkit.models.PaykitAmount @@ -307,6 +308,7 @@ sealed class PaykitPaymentRequestError(message: String) : AppError(message) { data object RequestExpired : PaykitPaymentRequestError("Payment request has expired") data object OperationInProgress : PaykitPaymentRequestError("Payment request operation is already in progress") data object SubscriptionTooLong : PaykitPaymentRequestError("Subscription proposal exceeds the message size limit") + data object RequestTooLong : PaykitPaymentRequestError("Payment request proposal exceeds the message size limit") } enum class PaykitPaymentRequestRefreshMode { @@ -898,10 +900,11 @@ class PaykitPaymentRequestRepo @Inject constructor( proposalDate ) actionVersion++ - val record = paykitSdkService.proposePaymentRequest( + val record = proposeWithinMessageLimit( counterparty = target.publicKey, proposal = proposal, expectedIdentity = expectedIdentity, + tooLong = PaykitPaymentRequestError.RequestTooLong, ) val reports = processPendingMessages(target.publicKey) @@ -924,6 +927,19 @@ class PaykitPaymentRequestRepo @Inject constructor( } } + private suspend fun proposeWithinMessageLimit( + counterparty: String, + proposal: PaykitPaymentRequestProposalTerms, + expectedIdentity: String, + tooLong: PaykitPaymentRequestError, + ): PaymentRequestRecord = runSuspendCatching { + paykitSdkService.proposePaymentRequest( + counterparty = counterparty, + proposal = proposal, + expectedIdentity = expectedIdentity, + ) + }.getOrElse { throw if (it.isPaykitMessageTooLarge()) tooLong else it } + private fun buildPaymentRequestProposal( draft: PaykitPaymentRequestDraft, endpointIdentifiers: List, @@ -996,10 +1012,11 @@ class PaykitPaymentRequestRepo @Inject constructor( val proposal = buildSubscriptionProposal(draft, name, description, iconUri, endpoints, clock.now()) PaykitSubscriptionProposal.validate(proposal) actionVersion++ - val record = paykitSdkService.proposePaymentRequest( + val record = proposeWithinMessageLimit( counterparty = target.publicKey, proposal = proposal, expectedIdentity = expectedIdentity, + tooLong = PaykitPaymentRequestError.SubscriptionTooLong, ) val reports = processPendingMessages(target.publicKey) val deliveryStatus = if (proposalWasSent(record, reports, generation, expectedIdentity)) { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b183375a7f..0245a30219 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -8601,7 +8601,17 @@ class AppViewModel @Inject constructor( ) } } - .onFailure(::toast) + .onFailure { + if (it is PaykitPaymentRequestError.RequestTooLong) { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = context.getString(R.string.wallet__payment_request_note_too_long), + ) + } else { + toast(it) + } + } } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 90942c35f0..a4f9a96c69 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1555,6 +1555,7 @@ لم تطابق تفاصيل الدفع الطلب. تم إلغاء الدفع. غير محدد ما الغرض من هذه الدفعة؟ + اختصر الملاحظة وحاول مرة أخرى. طلب أو دفع ادفع لـ %1$s أو اطلب دفعة. اطلب\n<accent>أو ادفع ₿</accent> 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 f56a40e03c..b8f85594a7 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1562,6 +1562,7 @@ Los datos del pago no coincidieron con la solicitud. Pago cancelado. No especificado ¿Para qué es este pago? + Acorte la nota y vuelva a intentarlo. Solicitar o pagar Pague a %1$s o solicite un pago. Solicitar\n<accent>o pagar ₿</accent> diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 9a549033cb..dc878905aa 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1559,6 +1559,7 @@ Les dades del pagament no coincidien amb la sol·licitud. Pagament cancel·lat. No especificat Per a què és aquest pagament? + Escurça la nota i torna-ho a provar. Sol·licitar o pagar Paga a %1$s o sol·licita un pagament. Sol·licita\n<accent>o paga ₿</accent> diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 573f3ece32..687f73b615 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1562,6 +1562,7 @@ Platební údaje neodpovídaly žádosti. Platba byla zrušena. Neuvedeno Za co je tato platba? + Zkraťte poznámku a zkuste to znovu. Požádat nebo zaplatit Zaplaťte kontaktu %1$s nebo požádejte o platbu. Požádat\n<accent>nebo zaplatit ₿</accent> diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 26edf14a5e..ca5edad794 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1561,6 +1561,7 @@ Die Zahlungsdetails stimmten nicht mit der Anforderung überein. Zahlung abgebrochen. Nicht angegeben Wofür ist diese Zahlung? + Kürze die Notiz und versuche es erneut. Anfordern oder bezahlen Bezahle %1$s oder fordere eine Zahlung an. Anfordern\n<accent>oder ₿ zahlen</accent> diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 620941b9eb..5f8646556e 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1556,6 +1556,7 @@ Τα στοιχεία πληρωμής δεν αντιστοιχούσαν στο αίτημα. Η πληρωμή ακυρώθηκε. Δεν καθορίστηκε Για τι είναι αυτή η πληρωμή; + Συντόμευσε τη σημείωση και δοκίμασε ξανά. Αίτημα ή πληρωμή Πλήρωσε τον/την %1$s ή ζήτησε πληρωμή. Αίτημα\n<accent>ή πληρωμή ₿</accent> diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 65ebd51cb8..0b296b1c57 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1558,6 +1558,7 @@ Los datos del pago no coinciden con la solicitud. Pago cancelado. Sin especificar ¿Para qué es este pago? + Acorta la nota e inténtalo de nuevo. Solicitar o pagar Paga a %1$s o solicita un pago. Solicitar\n<accent>o pagar ₿</accent> diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index ea266cdb7c..05f4d3a75f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1558,6 +1558,7 @@ Los datos del pago no coinciden con la solicitud. Pago cancelado. Sin especificar ¿Para qué es este pago? + Acorte la nota e inténtelo de nuevo. Solicitar o pagar Pague a %1$s o solicite un pago. Solicitar\n<accent>o pagar ₿</accent> diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 3b562b5059..1e00961bba 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1562,6 +1562,7 @@ Non précisé Note À quoi sert ce paiement ? + Raccourcissez la note et réessayez. Demander ou payer Payez %1$s ou demandez un paiement. Demander\n<accent>ou payer ₿</accent> diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 9affd1b720..5e8dd82a7b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1562,6 +1562,7 @@ Non specificato Nota A cosa serve questo pagamento? + Accorcia la nota e riprova. Richiedi o Paga Paga %1$s o richiedi un pagamento. Richiedi\n<accent>o Paga ₿</accent> diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 2e5b17021a..84ca8c0373 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1562,6 +1562,7 @@ De betaalgegevens kwamen niet overeen met het verzoek. Betaling geannuleerd. Niet opgegeven Waar is deze betaling voor? + Maak de notitie korter en probeer het opnieuw. Verzoeken of betalen Betaal %1$s of vraag een betaling aan. Verzoek\n<accent>of betaal ₿</accent> diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index eb8c4c4f50..8d71665df3 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1561,6 +1561,7 @@ Dane płatności nie odpowiadają żądaniu. Płatność anulowana. Nie określono Za co jest ta płatność? + Skróć notatkę i spróbuj ponownie. Zażądaj lub zapłać Zapłać %1$s lub zażądaj płatności. Zażądaj\n<accent>lub zapłać ₿</accent> diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index cc80fa335a..edebd2143e 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1562,6 +1562,7 @@ Os dados de pagamento não corresponderam à solicitação. Pagamento cancelado. Não especificado Para que é este pagamento? + Encurte a nota e tente novamente. Solicitar ou Pagar Pague %1$s ou solicite um pagamento. Solicitar\n<accent>ou Pagar ₿</accent> diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 1e3a1e04dc..3dba9c862d 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1556,6 +1556,7 @@ Os dados de pagamento não correspondem ao pedido. Pagamento cancelado. Não especificado Para que é este pagamento? + Encurte a nota e tente novamente. Pedir ou pagar Pague a %1$s ou peça um pagamento. Pedir\n<accent>ou pagar ₿</accent> diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 2224201bf7..954e35aca0 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1576,6 +1576,7 @@ Платёжные данные не совпали с запросом. Платёж отменён. Не указано За что этот платёж? + Сократите заметку и попробуйте снова. Запросить или Оплатить Заплатите %1$s или запросите платёж. Запросить\n<accent>или Оплатить ₿</accent> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 59c3d3c4ec..802a9c09ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1432,6 +1432,7 @@ Not specified Note What is this payment for? + Shorten the note and try again. Request Or Pay Pay %1$s or request a payment. Or Pay ₿]]> diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index 1a8ce42b85..29c6e05819 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -9,6 +9,7 @@ import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.OutboundPrivateMessageStatus import com.synonym.paykit.OutboundPrivateSendReport +import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitStateObservation import com.synonym.paykit.PaymentConversion import com.synonym.paykit.PaymentDeadline @@ -543,6 +544,28 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat verify(paykitSdkService).proposePaymentRequest(any(), any(), eq(LOCAL_IDENTITY)) } + @Test + fun `creator proposal the sdk finds too large reports subscription content that is too long`() = test { + val target = stubSubscriptionProposal() + whenever(paykitSdkService.proposePaymentRequest(any(), any(), eq(LOCAL_IDENTITY))).doSuspendableAnswer { + throw PaykitException.Protocol("message_too_large", "Private Application Message exceeds the limit") + } + + val result = sut.proposeSubscription( + draft = PaykitSubscriptionDraft( + amount = PaykitAmount(PaykitAsset.BTC, 100_000uL), + name = "Monthly support", + description = "Thank you", + frequency = PaykitRecurrenceUnit.Month, + expiresAt = clock.now().plus(60.seconds), + ), + target = target, + savedPublicKeys = listOf(COUNTERPARTY), + ) + + assertEquals(PaykitPaymentRequestError.SubscriptionTooLong, result.exceptionOrNull()) + } + @Test fun `creator proposal publishes real time while the subscription clock offset is on`() = test { val target = stubSubscriptionProposal() diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 029cc864ce..4da1a00f2e 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -2654,6 +2654,34 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { activity.checkRefreshAdmission(Priority.Background) } + @Test + fun `proposal too large for one message reports a note that is too long`() = test { + val target = stubProposal(paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE)) + val draft = PaykitPaymentRequestDraft(PaykitAmount(PaykitAsset.BTC, 1uL), "Lunch", clock.now().plus(60.seconds)) + whenever(paykitSdkService.proposePaymentRequest(any(), any(), eq(LOCAL_IDENTITY))).doSuspendableAnswer { + throw PaykitException.Protocol("message_too_large", "Private Application Message exceeds the limit") + } + + val result = sut.propose(draft, target, listOf(COUNTERPARTY)) + + assertSame(PaykitPaymentRequestError.RequestTooLong, result.exceptionOrNull()) + assertFalse(sut.isCreatingRequest.value) + } + + @Test + fun `other proposal protocol errors are reported unchanged`() = test { + val target = stubProposal(paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE)) + val draft = PaykitPaymentRequestDraft(PaykitAmount(PaykitAsset.BTC, 1uL), "Lunch", clock.now().plus(60.seconds)) + val failure = PaykitException.Protocol("protocol_error", "Link is not ready") + whenever(paykitSdkService.proposePaymentRequest(any(), any(), eq(LOCAL_IDENTITY))).doSuspendableAnswer { + throw failure + } + + val result = sut.propose(draft, target, listOf(COUNTERPARTY)) + + assertSame(failure, result.exceptionOrNull()) + } + @Test fun `proposal uses canonical terms and accepts available selected methods`() = test { val target = From 8037caa23edbb8e2d054770f47cd772c1c08d65f Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 6 Nov 2026 12:05:33 -0500 Subject: [PATCH 05/13] fix: explain refused pubky signup codes and network errors Co-Authored-By: Claude Opus 5.5 --- .../java/to/bitkit/ext/PaykitExceptionExt.kt | 6 +++++ .../bitkit/ui/utils/PubkyAuthErrorMessage.kt | 4 +++ app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-b+es+419/strings.xml | 1 + app/src/main/res/values-ca/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-el/strings.xml | 1 + app/src/main/res/values-es-rES/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../ui/utils/PubkyAuthErrorMessageTest.kt | 26 +++++++++++++++++++ 19 files changed, 52 insertions(+) diff --git a/app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt b/app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt index b8ba0e2c02..b445a55c2c 100644 --- a/app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt +++ b/app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt @@ -5,6 +5,9 @@ import com.synonym.paykit.PaykitException /** Protocol error code for an outbound Private Application Message larger than one pubky-noise message. */ private const val MESSAGE_TOO_LARGE_CODE = "message_too_large" +/** Identity error code for a signup code the homeserver refused during sign-up. */ +private const val SIGNUP_CODE_REJECTED_CODE = "signup_code_rejected" + fun Throwable.isPaykitIdentityError(): Boolean = generateSequence(this) { it.cause }.any { it is PaykitException.Identity } @@ -19,3 +22,6 @@ fun Throwable.isPaykitTemporarilyUnavailable(): Boolean = fun Throwable.isPaykitMessageTooLarge(): Boolean = generateSequence(this) { it.cause }.any { it is PaykitException.Protocol && it.code == MESSAGE_TOO_LARGE_CODE } + +fun Throwable.isPaykitSignupCodeRejected(): Boolean = + generateSequence(this) { it.cause }.any { it is PaykitException.Identity && it.code == SIGNUP_CODE_REJECTED_CODE } diff --git a/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt b/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt index 252c03ba5e..cd1b15e547 100644 --- a/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt +++ b/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt @@ -1,7 +1,9 @@ package to.bitkit.ui.utils import android.content.Context +import com.synonym.paykit.PaykitException import to.bitkit.R +import to.bitkit.ext.isPaykitSignupCodeRejected import to.bitkit.models.PubkyAuthRequestError import to.bitkit.repositories.WatchOnlyAccountError import to.bitkit.services.PubkyRingAuthTimeoutError @@ -27,6 +29,8 @@ private fun Throwable.pubkyAuthMessageResource() = when (this) { PubkyAuthRequestError.DuplicateBitkitClaim -> R.string.profile__auth_error_duplicate_claim is PubkyAuthRequestError.UnsupportedBitkitClaim -> R.string.profile__auth_error_unsupported_claim PubkyAuthRequestError.InvalidBitkitClaimCapabilities -> R.string.profile__auth_error_invalid_capabilities + is PaykitException.Identity if isPaykitSignupCodeRejected() -> R.string.profile__auth_error_signup_code_rejected + is PaykitException.Transport -> R.string.pubky_auth__identity_unavailable_desc is WatchOnlyAccountError.AuthorizationAccountMissing -> R.string.watch_only_accounts__setup_not_finished is WatchOnlyAccountError.InvalidAccountName -> R.string.watch_only_accounts__error_invalid_name is WatchOnlyAccountError.InvalidExtendedPublicKey -> R.string.watch_only_accounts__error_invalid_xpub diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index a4f9a96c69..974040021a 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1288,6 +1288,7 @@ الوصول المطلوب لا يطابق مطالبة Bitkit هذه. رابط تفويض Pubky هذا غير صالح. ينقص هذا التفويض مطالبة Bitkit المطلوبة. + لم يقبل الخادم الرئيسي رمز التسجيل هذا. حاول مرة أخرى أو استخدم رابط تسجيل آخر. لم يستجب مُرحّل التفويض في الوقت المحدد. يرجى المحاولة مرة أخرى. فشل التفويض مطالبة Bitkit هذه غير مدعومة. diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index b8f85594a7..5549a2b355 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1314,6 +1314,7 @@ El acceso solicitado no coincide con esta declaración de Bitkit. Este enlace de autorización de Pubky no es válido. A esta autorización le falta la declaración de Bitkit requerida. + El homeserver no aceptó este código de registro. Inténtelo de nuevo o use otro enlace de registro. El relay de autorización no respondió a tiempo. Inténtelo de nuevo. Falló la autorización Esta declaración de Bitkit no es compatible. diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index dc878905aa..0405c1b063 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1310,6 +1310,7 @@ L\'accés sol·licitat no coincideix amb aquesta declaració de Bitkit. Aquest enllaç d\'autorització de Pubky no és vàlid. A aquesta autorització li falta la declaració de Bitkit necessària. + El homeserver no ha acceptat aquest codi de registre. Torna-ho a provar o fes servir un altre enllaç de registre. El relé d\'autorització no ha respost a temps. Torna-ho a provar. Ha fallat l\'autorització Aquesta declaració de Bitkit no és compatible. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 687f73b615..98c17cc41f 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1312,6 +1312,7 @@ Požadovaný přístup neodpovídá tomuto nároku Bitkit. Tento autorizační odkaz Pubky je neplatný. V této autorizaci chybí požadovaný nárok Bitkit. + Homeserver tento registrační kód nepřijal. Zkuste to znovu nebo použijte jiný registrační odkaz. Autorizační relay neodpověděl včas. Zkuste to prosím znovu. Autorizace se nezdařila Tento nárok Bitkit není podporován. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ca5edad794..bb3581d156 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1310,6 +1310,7 @@ Der angeforderte Zugriff passt nicht zu diesem Bitkit-Claim. Dieser Pubky-Autorisierungslink ist ungültig. Dieser Autorisierung fehlt der erforderliche Bitkit-Claim. + Der Homeserver hat diesen Registrierungscode nicht akzeptiert. Versuche es erneut oder nutze einen anderen Registrierungslink. Das Autorisierungs-Relay hat nicht rechtzeitig geantwortet. Bitte versuche es erneut. Autorisierung fehlgeschlagen Dieser Bitkit-Claim wird nicht unterstützt. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 5f8646556e..011fddab51 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1294,6 +1294,7 @@ Η ζητούμενη πρόσβαση δεν αντιστοιχεί σε αυτή τη δήλωση Bitkit. Αυτός ο σύνδεσμος εξουσιοδότησης Pubky δεν είναι έγκυρος. Σε αυτή την εξουσιοδότηση λείπει η απαιτούμενη δήλωση Bitkit. + Ο homeserver δεν δέχτηκε αυτόν τον κωδικό εγγραφής. Δοκίμασε ξανά ή χρησιμοποίησε άλλον σύνδεσμο εγγραφής. Ο αναμεταδότης εξουσιοδότησης δεν απάντησε εγκαίρως. Δοκίμασε ξανά. Η εξουσιοδότηση απέτυχε Αυτή η δήλωση Bitkit δεν υποστηρίζεται. diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 0b296b1c57..61bde2b9b6 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1308,6 +1308,7 @@ El acceso solicitado no coincide con esta reclamación de Bitkit. Este enlace de autorización de Pubky no es válido. A esta autorización le falta la reclamación de Bitkit necesaria. + El servidor principal no ha aceptado este código de registro. Inténtalo de nuevo o usa otro enlace de registro. El relé de autorización no respondió a tiempo. Inténtalo de nuevo. Error de autorización Esta reclamación de Bitkit no es compatible. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 05f4d3a75f..42df93b603 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1307,6 +1307,7 @@ El acceso solicitado no coincide con esta reclamación de Bitkit. Este enlace de autorización de Pubky no es válido. A esta autorización le falta la reclamación de Bitkit necesaria. + El servidor principal no aceptó este código de registro. Inténtelo de nuevo o use otro enlace de registro. El relé de autorización no respondió a tiempo. Inténtelo de nuevo. Error de autorización Esta reclamación de Bitkit no es compatible. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 1e00961bba..0825d6c326 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1308,6 +1308,7 @@ L\'accès demandé ne correspond pas à cette déclaration Bitkit. Ce lien d\'autorisation Pubky n\'est pas valide. Il manque à cette autorisation la déclaration Bitkit requise. + Le serveur domestique n\'a pas accepté ce code d\'inscription. Réessayez ou utilisez un autre lien d\'inscription. Le relais d\'autorisation n\'a pas répondu à temps. Veuillez réessayer. Échec de l\'autorisation Cette déclaration Bitkit n\'est pas prise en charge. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 5e8dd82a7b..5513adae36 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1313,6 +1313,7 @@ L\'accesso richiesto non corrisponde a questa claim Bitkit. Questo link di autorizzazione Pubky non è valido. In questa autorizzazione manca la claim Bitkit richiesta. + L\'homeserver non ha accettato questo codice di registrazione. Riprova o usa un altro link di registrazione. Il relay di autorizzazione non ha risposto in tempo. Riprova. Autorizzazione Non Riuscita Questa claim Bitkit non è supportata. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 84ca8c0373..f2a274b83f 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1310,6 +1310,7 @@ De gevraagde toegang komt niet overeen met deze Bitkit-claim. Deze Pubky-autorisatielink is ongeldig. Deze autorisatie mist de vereiste Bitkit-claim. + De homeserver heeft deze aanmeldcode niet geaccepteerd. Probeer het opnieuw of gebruik een andere aanmeldlink. De autorisatierelay reageerde niet op tijd. Probeer het opnieuw. Autorisatie mislukt Deze Bitkit-claim wordt niet ondersteund. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 8d71665df3..dbcbd0cf1f 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1311,6 +1311,7 @@ Żądany dostęp nie odpowiada temu oświadczeniu Bitkit. Ten link autoryzacji Pubky jest nieprawidłowy. W tej autoryzacji brakuje wymaganego oświadczenia Bitkit. + Serwer domowy nie zaakceptował tego kodu rejestracji. Spróbuj ponownie lub użyj innego linku rejestracyjnego. Przekaźnik autoryzacji nie odpowiedział na czas. Spróbuj ponownie. Autoryzacja nie powiodła się To oświadczenie Bitkit nie jest obsługiwane. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index edebd2143e..bc4fd80764 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1313,6 +1313,7 @@ O acesso solicitado não corresponde a esta declaração da Bitkit. Este link de autorização do Pubky é inválido. Esta autorização não contém a declaração da Bitkit necessária. + O homeserver não aceitou este código de cadastro. Tente novamente ou use outro link de cadastro. O relay de autorização não respondeu a tempo. Tente novamente. Falha na Autorização Esta declaração da Bitkit não é suportada. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 3dba9c862d..67beaee223 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1287,6 +1287,7 @@ O acesso pedido não corresponde a esta declaração Bitkit. Este link de autorização Pubky é inválido. Falta a esta autorização a declaração Bitkit obrigatória. + O homeserver não aceitou este código de registo. Tente novamente ou use outra ligação de registo. O relay de autorização não respondeu a tempo. Tente novamente. Falha na autorização Esta declaração Bitkit não é suportada. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 954e35aca0..fbe5fbe579 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1328,6 +1328,7 @@ Запрошенный доступ не соответствует этому заявлению Bitkit. Эта ссылка авторизации Pubky недействительна. В этой авторизации отсутствует обязательное заявление Bitkit. + Домашний сервер не принял этот код регистрации. Попробуйте снова или используйте другую ссылку для регистрации. Ретранслятор авторизации не ответил вовремя. Пожалуйста, попробуйте снова. Ошибка Авторизации Это заявление Bitkit не поддерживается. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 802a9c09ae..e48f721964 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -620,6 +620,7 @@ The requested access does not match this Bitkit claim. This Pubky authorization link is invalid. This authorization is missing its required Bitkit claim. + The homeserver did not accept this signup code. Try again or use another signup link. The authorization relay did not respond in time. Please try again. Authorization Failed This Bitkit claim is not supported. diff --git a/app/src/test/java/to/bitkit/ui/utils/PubkyAuthErrorMessageTest.kt b/app/src/test/java/to/bitkit/ui/utils/PubkyAuthErrorMessageTest.kt index c8c5df8432..c7393e5cfa 100644 --- a/app/src/test/java/to/bitkit/ui/utils/PubkyAuthErrorMessageTest.kt +++ b/app/src/test/java/to/bitkit/ui/utils/PubkyAuthErrorMessageTest.kt @@ -2,6 +2,7 @@ package to.bitkit.ui.utils import android.content.Context import com.synonym.bitkitcore.PubkyException +import com.synonym.paykit.PaykitException import org.junit.Test import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -41,4 +42,29 @@ class PubkyAuthErrorMessageTest : BaseUnitTest() { assertEquals(localizedMessage, AppError(AppError(error)).localizedPubkyAuthMessage(context)) } } + + @Test + fun `refused signup codes and transport failures show their own descriptions`() { + val cases = listOf( + PaykitException.Identity("signup_code_rejected", "sign up") to + R.string.profile__auth_error_signup_code_rejected, + PaykitException.Transport("transport_error", "sign up") to R.string.pubky_auth__identity_unavailable_desc, + ) + cases.forEach { (error, resource) -> + val localizedMessage = "Localized message for $resource" + whenever(context.getString(resource)).thenReturn(localizedMessage) + + assertEquals(localizedMessage, AppError(error).localizedPubkyAuthMessage(context)) + } + } + + @Test + fun `other identity errors keep the localized fallback`() { + val localizedMessage = "Localized unknown error" + whenever(context.getString(R.string.common__error_body)).thenReturn(localizedMessage) + + val error = PaykitException.Identity("identity_error", "sign up") + + assertEquals(localizedMessage, AppError(error).localizedPubkyAuthMessage(context)) + } } From c1535260bda370471b287c0389a42e2eb27116c1 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 6 Nov 2026 12:15:13 -0500 Subject: [PATCH 06/13] test: pin pubky sign up outcomes for paykit rc6 Co-Authored-By: Claude Opus 5.5 --- .../to/bitkit/repositories/PubkyRepoTest.kt | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 4aecb53aac..77a21e6889 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -75,6 +75,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.data.sharedpubky.SharedPubkyClient import to.bitkit.data.sharedpubky.SharedPubkyContract +import to.bitkit.ext.isPaykitTemporarilyUnavailable import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PubkyAuthClaim import to.bitkit.models.PubkyAuthClaim.Item @@ -731,6 +732,43 @@ class PubkyRepoTest : BaseUnitTest() { httpClient.close() } + @Test + fun `createIdentity signs in when sign up finds the identity on another homeserver`() = test { + val httpClient = identityHttpClient() + sut = createSut(httpClient) + stubSignupKeys() + whenever(pubkyService.signUp("secret", "test-homeserver", "test-code")).thenAnswer { + throw AppError(PaykitException.Policy("policy_error", "Identity lives on another homeserver")) + } + whenever(pubkyService.signIn("secret")).thenReturn(Unit) + whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock()) + + val result = sut.createIdentity("Test", "", emptyList(), emptyList(), null) + httpClient.close() + + assertTrue(result.isSuccess) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + verifyBlocking(pubkyService) { signIn("secret") } + } + + @Test + fun `createIdentity reports a transport failure without signing up again`() = test { + val httpClient = identityHttpClient() + sut = createSut(httpClient) + stubSignupKeys() + val offline = PaykitException.Transport("transport_error", "Homeserver unavailable") + whenever(pubkyService.signUp("secret", "test-homeserver", "test-code")).thenAnswer { throw AppError(offline) } + whenever(pubkyService.signIn("secret")).thenAnswer { throw AppError(offline) } + + val result = sut.createIdentity("Test", "", emptyList(), emptyList(), null) + httpClient.close() + + assertTrue(result.exceptionOrNull()?.isPaykitTemporarilyUnavailable() == true) + assertNull(sut.publicKey.value) + verifyBlocking(pubkyService, times(1)) { signUp(any(), any(), any()) } + verifyBlocking(pubkyService, never()) { publishPaykitProfile(any()) } + } + @Test fun `createIdentity should preserve signup session when pending profile publication fails`() = test { val registeredSession = mock() From f61b231ee2502095cbec8a14ad53814314a2e224 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 6 Nov 2026 12:21:14 -0500 Subject: [PATCH 07/13] fix: measure usdt proofs with the largest valid log index Co-Authored-By: Claude Opus 5.5 --- .../java/to/bitkit/repositories/PaykitPaymentProofRepo.kt | 7 ++++--- .../to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 7c5d3450cb..88e279168e 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -1704,8 +1704,9 @@ private const val USDT_TRANSACTION_HASH_DIGITS = 64 /** Hex digits of a 65-byte EIP-712 signature after its `0x` prefix. */ private const val USDT_SIGNATURE_DIGITS = 130 -/** Decimal digits of the largest uint256 receipt log index. */ -private const val USDT_LOG_INDEX_DIGITS = 78 +/** The largest uint256 receipt log index, the longest one the SDK accepts in an ERC-20 proof. */ +internal const val USDT_MAX_RECEIPT_LOG_INDEX = + "115792089237316195423570985008687907853269984665640564039457584007913129639935" /** The longest proof Bitkit submits for this kind, so a measured proof is never shorter than the one sent. */ internal fun PaykitPaymentProofKind.longestProofJson(): String = when (this) { @@ -1715,7 +1716,7 @@ internal fun PaykitPaymentProofKind.longestProofJson(): String = when (this) { UsdtPaymentProof( chainId = PaykitUsdt.CHAIN_ID, transactionHash = "0x" + "0".repeat(USDT_TRANSACTION_HASH_DIGITS), - receiptLogIndex = "9".repeat(USDT_LOG_INDEX_DIGITS), + receiptLogIndex = USDT_MAX_RECEIPT_LOG_INDEX, signature = "0x" + "0".repeat(USDT_SIGNATURE_DIGITS), ), ), diff --git a/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt index 14a114b0bc..49f98c7539 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt @@ -64,6 +64,7 @@ import to.bitkit.services.UsdtService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import to.bitkit.viewmodels.ContactPaymentContext +import java.math.BigInteger import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse @@ -826,7 +827,8 @@ class PaykitUsdtPaymentRepoTest : BaseUnitTest() { assertEquals(PaykitUsdt.CHAIN_ID, proof.chainId) assertEquals(66, proof.transactionHash.length) - assertEquals("9".repeat(78), proof.receiptLogIndex) + assertEquals(78, proof.receiptLogIndex.length) + assertEquals(BigInteger.ONE.shiftLeft(256) - BigInteger.ONE, BigInteger(proof.receiptLogIndex)) assertEquals(132, proof.signature.length) } From 813ff4f5e336e5a16083f9bfd4d5a64d958eee41 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 6 Nov 2026 12:21:14 -0500 Subject: [PATCH 08/13] fix: show before request ahead of other usdt receipt states Co-Authored-By: Claude Opus 5.5 --- .../bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index 414449d9a0..ee46ead94f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -820,8 +820,8 @@ private fun PaymentRequestsPreview() { internal fun paykitUsdtReceiptText(receipt: PaykitUsdtReceipt): String { val label = when { !receipt.verified -> R.string.wallet__payment_request_pending - receipt.underpaid -> R.string.wallet__payment_request_underpaid receipt.beforeRequest -> R.string.wallet__payment_request_before_request + receipt.underpaid -> R.string.wallet__payment_request_underpaid receipt.afterExpiry -> R.string.wallet__payment_request_after_expiry else -> R.string.wallet__payment_request_payment_verified } From ff2cc4d85c5fc8079fc150477e7ce03417584530 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 6 Nov 2026 12:26:56 -0500 Subject: [PATCH 09/13] docs: add paykit note and signup code journeys Co-Authored-By: Claude Opus 5.5 --- journeys/payment-requests/README.md | 4 ++++ .../payment-requests/request-note-too-long.xml | 18 ++++++++++++++++++ journeys/pubky-auth/README.md | 7 +++++-- .../pubky-auth/grant-signup-rejected-code.xml | 17 +++++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 journeys/payment-requests/request-note-too-long.xml create mode 100644 journeys/pubky-auth/grant-signup-rejected-code.xml diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 548bf24d3c..7fa7cdbe79 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -37,6 +37,10 @@ The accepted journey uses: Rejected fixture shapes stay in unit tests because Bitkit intentionally does not present requests that fail the contract gate. +`request-note-too-long.xml` checks that a request whose note does not fit one Paykit message is +refused with "Shorten the note and try again." before anything is sent, and sends after the note is +shortened. It uses the same two-instance setup as `request-summary.xml`. + `success-dismissal.xml` matches iOS's journey: after paying a Lightning request, closing success must leave no error toast or duplicate payment; closing a later unpaid request must not send it. Use two linked disposable regtest wallets and observe or record dismissal to catch short-lived toasts. diff --git a/journeys/payment-requests/request-note-too-long.xml b/journeys/payment-requests/request-note-too-long.xml new file mode 100644 index 0000000000..b9808cc522 --- /dev/null +++ b/journeys/payment-requests/request-note-too-long.xml @@ -0,0 +1,18 @@ + + + Verifies that a Payment Request whose note does not fit one Paykit message is refused before anything is sent, with a message asking to shorten the note, and that the same request sends once the note is shortened. Requires two Bitkit instances saved as each other's contacts and linked as identities. See README.md. Paste the long note from the device clipboard rather than typing it, since adb input cannot type emoji. + + + Copy a note of 256 "💜" characters to the requester's clipboard + On the requester, open the payer contact, tap Pay (testTag "ContactPay") and tap Request on the sheet + Enter 1,000 sats on the Payment Request amount screen and tap Continue (testTag "PaymentRequestAmountContinue") + Paste the copied note into the note field (testTag "PaymentRequestNote") + Tap Send (testTag "PaymentRequestSend") + Verify an error toast says "Shorten the note and try again." and the request screen stays open with Send (testTag "PaymentRequestSend") visible + On the payer, verify no new Payment Request appears + On the requester, replace the note with "Coffee" and tap Send (testTag "PaymentRequestSend") + Verify completion (testTag "PaymentRequestSent") is shown + On the payer, verify the Payment Request confirmation screen (testTag "PaymentRequestConfirm") appears with 1,000 sats and For (testTag "PaymentRequestFor") shows "Coffee" + Close the Payment Request confirmation screen without paying + + diff --git a/journeys/pubky-auth/README.md b/journeys/pubky-auth/README.md index 83e54928ff..45dffe13df 100644 --- a/journeys/pubky-auth/README.md +++ b/journeys/pubky-auth/README.md @@ -18,10 +18,13 @@ - `grant-signup.xml` checks consent, cancellation, homeserver registration, and profile setup for a fresh identity. - `grant-signup-existing-identity.xml` checks approval with an existing identity without opening profile setup. +- `grant-signup-rejected-code.xml` checks the message for a signup code the homeserver refuses, and that no identity is created. -Both require a fresh `pubkyauth://signup_grant` URL from a controlled requesting app, a reachable +The first two require a fresh `pubkyauth://signup_grant` URL from a controlled requesting app, a reachable homeserver and authorization relay, and a valid invite when required. The new-identity fixture -uses ordinary app permissions without a Bitkit companion claim. +uses ordinary app permissions without a Bitkit companion claim. The rejected-code journey instead +needs a URL whose signup token that homeserver refuses, such as the token of a link another wallet +already completed. Deliver each grant signup request through the main wallet scanner: copy the complete URL to the device clipboard, tap **Scan**, then **Paste**, and allow clipboard access if prompted. Camera diff --git a/journeys/pubky-auth/grant-signup-rejected-code.xml b/journeys/pubky-auth/grant-signup-rejected-code.xml new file mode 100644 index 0000000000..4a9af76994 --- /dev/null +++ b/journeys/pubky-auth/grant-signup-rejected-code.xml @@ -0,0 +1,17 @@ + + + Verifies that a signup link whose signup code the homeserver refuses shows a specific message, keeps the authorization sheet open for another try, and creates no identity. Precondition: an isolated unlocked wallet with Paykit enabled and no Pubky identity. A controlled requesting app supplies a signup_grant URL for a reachable homeserver and relay whose signup token that homeserver has already used or does not know, for example the token of a signup_grant URL another wallet already completed. The URL requests ordinary app permissions without a Bitkit companion claim. + + + Copy the prepared signup_grant URL with the refused signup token to the device clipboard + Tap the main wallet Scan button (id "Scan") + If camera permission is requested, deny it to use clipboard input + Tap Paste + Allow clipboard access if prompted + Tap Authorize (id "PubkyAuthAuthorize") and complete PIN or biometric approval when enabled + Verify an error toast says "The homeserver did not accept this signup code. Try again or use another signup link." + Verify the authorization sheet is still open with Authorize (id "PubkyAuthAuthorize") enabled + Verify the Create Profile screen (id "CreateProfileSave") is not shown + Close the authorization sheet and verify the wallet still has no Pubky identity + + From 83e8365d0e69a5638f9829a50e85b853a909f816 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 6 Nov 2026 12:26:56 -0500 Subject: [PATCH 10/13] chore: add paykit rc6 changelog fragment Co-Authored-By: Claude Opus 5.5 --- changelog.d/next/1803.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1803.fixed.md diff --git a/changelog.d/next/1803.fixed.md b/changelog.d/next/1803.fixed.md new file mode 100644 index 0000000000..23eb778524 --- /dev/null +++ b/changelog.d/next/1803.fixed.md @@ -0,0 +1 @@ +Paykit payment requests now check that a payment proof fits before paying, explain notes that are too long and refused signup codes, and no longer count a USDT transfer sent before its request as payment. From 4e29ab2b524eaa5c1c7c9d069917df19a887e639 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 6 Nov 2026 12:37:16 -0500 Subject: [PATCH 11/13] test: wrap a paykit delivery report fixture Co-Authored-By: Claude Opus 5.5 --- .../test/java/to/bitkit/services/PaykitSdkServiceTest.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 62df6dac55..039aebc1bd 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -226,7 +226,13 @@ class PaykitSdkServiceTest { } "revision" } - val report = PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()) + val report = PrivatePaymentListDeliveryReport( + emptyList(), + emptyList(), + emptyList(), + emptyList(), + emptyList(), + ) whenever(sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound(any(), any())).thenReturn(report) val updates = listOf(PrivatePaymentListReservationUpdateInput(RING_PUBKY, listOf(mock()))) val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } From 4ca7faa3b15113db510546736a3c3e5fad9f3980 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 6 Nov 2026 12:42:37 -0500 Subject: [PATCH 12/13] fix: keep allowance requests with oversized proofs manual Co-Authored-By: Claude Opus 5.5 --- .../repositories/PaykitAllowanceExecutor.kt | 16 ++++++++ .../PaykitAllowanceExecutorTest.kt | 37 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index db0ca587e6..b74744ba79 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -710,6 +710,22 @@ class PaykitAllowanceExecutor @Inject constructor( Logger.info("Kept an incoming request manual: the payee offers no Lightning invoice", context = TAG) return PaykitAllowanceAutoPayResult.MANUAL } + // Measured before the acceptance, so a proof that can never be delivered is never accepted on the payee's side. + val proofFits = runCatching { + paykitSdkService.requirePaymentProofFits( + paymentReference = request.paymentReference, + paymentAppId = payment.appId, + paymentEndpointIdentifier = payment.endpoint.methodId.rawValue, + kind = PaykitPaymentProofKind.Lightning, + billingPeriod = request.billingPeriod, + conversionQuoteId = null, + allowanceId = candidate.allowanceId, + ) + }.isSuccess + if (!proofFits) { + Logger.info("Kept an incoming request manual: its payment proof would not fit one message", context = TAG) + return PaykitAllowanceAutoPayResult.MANUAL + } if (!admits(activation)) { Logger.info("Kept an incoming request manual: allowances turned off before acceptance", context = TAG) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt index 03126ce52c..c7e213ac29 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -426,6 +426,43 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { assertEquals(listOf(Stage.SUCCEEDED), sut.localState(identity).journal.map { it.stage }) } + @Test + fun `payment proof is measured with its allowance before the request is accepted`() = test { + val request = fixtures.paymentRequest() + whenever( + sdk.paymentProofEventLength(any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull()) + ).thenReturn(PaykitSdkService.MAX_MESSAGE_BYTES.toUInt()) + + val result = sut.autoPay(request, listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.STARTED, result) + val order = inOrder(sdk, payer) + order.verify(sdk).paymentProofEventLength( + paymentReference = request.paymentReference, + paymentAppId = PAYMENT_APP_ID, + paymentEndpointIdentifier = fixtures.lightningIdentifier, + proofJson = PaykitPaymentProofKind.Lightning.longestProofJson(), + billingPeriod = null, + conversionQuoteId = null, + allowanceId = ALLOWANCE_ID, + ) + order.verify(payer).acceptOnThisInstall(eq(request), any()) + } + + @Test + fun `request whose payment proof would not fit one message stays manual without acceptance`() = test { + whenever( + sdk.paymentProofEventLength(any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull()) + ).thenReturn(PaykitSdkService.MAX_MESSAGE_BYTES.toUInt() + 1u) + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + verify(payer, never()).acceptOnThisInstall(any(), any()) + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any(), any()) + verify(sdk, never()).reserveAutomaticPayment(any(), any(), any()) + } + @Test fun `blocked candidate stays manual without acceptance`() = test { candidates = listOf(candidate(blocked = AllowanceAccountingBlock.SharedRule("amount_outside_range"))) From 7cc585b5c3a1d49dbf2db3a4da421777b524e45d Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 6 Nov 2026 12:59:45 -0500 Subject: [PATCH 13/13] fix: treat unmeasurable paykit proofs as unavailable Co-Authored-By: Claude Opus 5.5 --- .../repositories/PaykitAllowanceExecutor.kt | 2 +- .../repositories/PaykitPaymentProofRepo.kt | 29 ++++++++++++------- .../PaykitPaymentProofRepoTest.kt | 14 +++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 27 +++++++++++++++++ 4 files changed, 61 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index b74744ba79..aadfe886e4 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -711,7 +711,7 @@ class PaykitAllowanceExecutor @Inject constructor( return PaykitAllowanceAutoPayResult.MANUAL } // Measured before the acceptance, so a proof that can never be delivered is never accepted on the payee's side. - val proofFits = runCatching { + val proofFits = runSuspendCatching { paykitSdkService.requirePaymentProofFits( paymentReference = request.paymentReference, paymentAppId = payment.appId, diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 88e279168e..5576a7dfe5 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -1725,9 +1725,13 @@ internal fun PaykitPaymentProofKind.longestProofJson(): String = when (this) { proofJson(this, "0".repeat(BITCOIN_PROOF_DATA_LENGTH)) } +/** Log context for measuring a Payment Proof before payment. */ +private const val PROOF_SIZE_TAG = "PaykitProofSize" + /** * Fails with [PaykitPaymentRequestError.RequestUnavailable] when the longest proof of [kind] for these values would - * not fit one Private Application Message, so nothing is paid for a proof that could never be delivered. + * not fit one Private Application Message, or cannot be measured, so nothing is paid for a proof that could never be + * delivered. */ internal fun PaykitSdkService.requirePaymentProofFits( paymentReference: String, @@ -1738,15 +1742,20 @@ internal fun PaykitSdkService.requirePaymentProofFits( conversionQuoteId: String?, allowanceId: String?, ) { - val length = paymentProofEventLength( - paymentReference = paymentReference, - paymentAppId = paymentAppId, - paymentEndpointIdentifier = paymentEndpointIdentifier, - proofJson = kind.longestProofJson(), - billingPeriod = billingPeriod, - conversionQuoteId = conversionQuoteId, - allowanceId = allowanceId, - ) + val length = runCatching { + paymentProofEventLength( + paymentReference = paymentReference, + paymentAppId = paymentAppId, + paymentEndpointIdentifier = paymentEndpointIdentifier, + proofJson = kind.longestProofJson(), + billingPeriod = billingPeriod, + conversionQuoteId = conversionQuoteId, + allowanceId = allowanceId, + ) + }.getOrElse { + Logger.warn("Failed to measure a Paykit payment proof", it, context = PROOF_SIZE_TAG) + throw PaykitPaymentRequestError.RequestUnavailable + } if (length > PaykitSdkService.MAX_MESSAGE_BYTES.toUInt()) throw PaykitPaymentRequestError.RequestUnavailable } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index ac0d934ed9..4ea9a154cb 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -292,6 +292,20 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService, never()).readPaymentRequests(any(), anyOrNull()) } + @Test + fun `proof the sdk cannot measure fails before payment as unavailable`() = test { + val request = paymentRequest(MethodId.Bolt11.rawValue) + whenever( + paykitSdkService.paymentProofEventLength(any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull()) + ).thenAnswer { throw PaykitException.Protocol("protocol_error", "Payment Proof cannot be measured") } + + val result = paymentProofRepo() + .prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning) + + assertEquals(PaykitPaymentRequestError.RequestUnavailable, result.exceptionOrNull()) + assertTrue(storedProofs.isEmpty()) + } + @Test fun `proof snapshot waits for local persistence to finish`() = test { val request = paymentRequest(MethodId.Bolt11.rawValue) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index f19870423c..f7b05e56d6 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -11671,6 +11671,33 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) } + @Test + fun `payment request too long to send asks to shorten the note`() = test { + val target = PaykitPaymentRequestTarget("pubkyrecipient") + val draft = PaykitPaymentRequestDraft( + amount = PaykitAmount(PaykitAsset.BTC, 1_000uL), + note = "💜".repeat(256), + expiresAt = Clock.System.now() + 60.seconds, + ) + whenever(context.getString(R.string.common__error)).thenReturn("Error") + whenever(context.getString(R.string.wallet__payment_request_note_too_long)) + .thenReturn("Shorten the note and try again.") + whenever(paykitPaymentRequestRepo.propose(draft, target, emptyList())) + .thenReturn(Result.failure(PaykitPaymentRequestError.RequestTooLong)) + var created = false + + sut.createPaymentRequest(draft, target) { created = true } + runCurrent() + + assertFalse(created) + verify(toastManager).enqueue( + check { + assertEquals(Toast.ToastType.ERROR, it.type) + assertEquals("Shorten the note and try again.", it.description) + } + ) + } + @Test fun `incoming payment request closes when it is no longer pending`() = test { val request = paymentRequest()