diff --git a/app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt b/app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt index 42133dc7c5..b445a55c2c 100644 --- a/app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt +++ b/app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt @@ -2,6 +2,12 @@ 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" + +/** 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 } @@ -13,3 +19,9 @@ 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 } + +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/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/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index db0ca587e6..aadfe886e4 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 = runSuspendCatching { + 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/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index a07a0520e6..5576a7dfe5 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,70 @@ 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 + +/** 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) { + PaykitPaymentProofKind.Usdt -> Json.encodeToString( + PaykitUsdtProof.serializer(), + PaykitUsdtProof( + UsdtPaymentProof( + chainId = PaykitUsdt.CHAIN_ID, + transactionHash = "0x" + "0".repeat(USDT_TRANSACTION_HASH_DIGITS), + receiptLogIndex = USDT_MAX_RECEIPT_LOG_INDEX, + signature = "0x" + "0".repeat(USDT_SIGNATURE_DIGITS), + ), + ), + ) + PaykitPaymentProofKind.Lightning, PaykitPaymentProofKind.Onchain -> + 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, or cannot be measured, 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 = 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 +} + 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/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/repositories/PaykitUsdtPaymentRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitUsdtPaymentRepo.kt index 2d9e01aaea..78b0c46176 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 @@ -47,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 @@ -95,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 @@ -115,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, @@ -138,39 +144,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 +406,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, @@ -665,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) { @@ -742,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/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/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index c7f57fc0df..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,6 +820,7 @@ private fun PaymentRequestsPreview() { internal fun paykitUsdtReceiptText(receipt: PaykitUsdtReceipt): String { val label = when { !receipt.verified -> R.string.wallet__payment_request_pending + 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 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/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c592a06aa7..a7650ffedb 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -8602,7 +8602,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 a980a803e2..495eaf0901 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1290,6 +1290,7 @@ الوصول المطلوب لا يطابق مطالبة Bitkit هذه. رابط تفويض Pubky هذا غير صالح. ينقص هذا التفويض مطالبة Bitkit المطلوبة. + لم يقبل الخادم الرئيسي رمز التسجيل هذا. حاول مرة أخرى أو استخدم رابط تسجيل آخر. لم يستجب مُرحّل التفويض في الوقت المحدد. يرجى المحاولة مرة أخرى. فشل التفويض مطالبة Bitkit هذه غير مدعومة. @@ -1543,6 +1544,7 @@ قبول الدفع بـ مستلم · بعد انتهاء الصلاحية المبلغ + مستلم · قبل الطلب اختر المستلم تجاهل أدخل pubky @@ -1556,6 +1558,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 8f861e9747..50ca4df092 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1316,6 +1316,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. @@ -1550,6 +1551,7 @@ Aceptar pago en Recibido · Tras vencer Monto + Recibido · Antes de la solicitud Elegir destinatario Descartar Introduzca un pubky @@ -1563,6 +1565,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 a196b80a43..27ea0d2ea2 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1312,6 +1312,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. @@ -1547,6 +1548,7 @@ Acceptar pagament en Rebut · Després del venciment Import + Rebut · Abans de la sol·licitud Triar destinatari Descartar Introdueix un pubky @@ -1560,6 +1562,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 fbbcd39836..5ced1b12bf 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1314,6 +1314,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. @@ -1550,6 +1551,7 @@ Přijmout platbu v Přijato · Po vypršení Částka + Přijato · Před žádostí Vybrat příjemce Odmítnout Zadejte pubky @@ -1563,6 +1565,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 cc4b02c58d..6480f9c41e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1312,6 +1312,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. @@ -1549,6 +1550,7 @@ Zahlung akzeptieren in Empfangen · Nach Ablauf Betrag + Empfangen · Vor der Anforderung Empfänger wählen Verwerfen pubky eingeben @@ -1562,6 +1564,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 c1f1dbf073..cc146d83e1 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1296,6 +1296,7 @@ Η ζητούμενη πρόσβαση δεν αντιστοιχεί σε αυτή τη δήλωση Bitkit. Αυτός ο σύνδεσμος εξουσιοδότησης Pubky δεν είναι έγκυρος. Σε αυτή την εξουσιοδότηση λείπει η απαιτούμενη δήλωση Bitkit. + Ο homeserver δεν δέχτηκε αυτόν τον κωδικό εγγραφής. Δοκίμασε ξανά ή χρησιμοποίησε άλλον σύνδεσμο εγγραφής. Ο αναμεταδότης εξουσιοδότησης δεν απάντησε εγκαίρως. Δοκίμασε ξανά. Η εξουσιοδότηση απέτυχε Αυτή η δήλωση Bitkit δεν υποστηρίζεται. @@ -1544,6 +1545,7 @@ Αποδοχή πληρωμής σε Ελήφθη · Μετά τη λήξη Ποσό + Ελήφθη · Πριν από το αίτημα Επιλογή παραλήπτη Απόρριψη Εισαγωγή pubky @@ -1557,6 +1559,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 e5936b5c0c..3085f5a5ed 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1310,6 +1310,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. @@ -1546,6 +1547,7 @@ Aceptar pago en Recibido · Tras caducar Cantidad + Recibido · Antes de la solicitud Elegir destinatario Descartar Introducir pubky @@ -1559,6 +1561,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 753d66b199..5693d3d454 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1309,6 +1309,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. @@ -1546,6 +1547,7 @@ Aceptar pago en Recibido · Tras caducar Cantidad + Recibido · Antes de la solicitud Elegir destinatario Descartar Introducir pubky @@ -1559,6 +1561,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 07571291b7..7828ed2970 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1310,6 +1310,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. @@ -1547,6 +1548,7 @@ Accepter le paiement en Reçu · Après expiration Montant + Reçu · Avant la demande Choisir le destinataire Contact Date @@ -1563,6 +1565,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 394bb8d182..5519440cbb 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1315,6 +1315,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. @@ -1549,6 +1550,7 @@ Accetta pagamento in Ricevuto · Dopo la scadenza Importo + Ricevuto · Prima della richiesta Scegli Destinatario Ignora Inserisci pubky @@ -1563,6 +1565,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 8157a2c01e..38a8e71606 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1312,6 +1312,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. @@ -1549,6 +1550,7 @@ Betaling accepteren in Ontvangen · Na vervaldatum Bedrag + Ontvangen · Vóór het verzoek Ontvanger kiezen Contact Negeren @@ -1563,6 +1565,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 4c12b45f7e..31ed097c7d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1313,6 +1313,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. @@ -1549,6 +1550,7 @@ Akceptuj płatność w Otrzymano · Po terminie Kwota + Otrzymano · Przed żądaniem Wybierz odbiorcę Odrzuć Wprowadź pubky @@ -1562,6 +1564,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 a789fa52d9..84b5f337a1 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1315,6 +1315,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. @@ -1550,6 +1551,7 @@ Aceitar pagamento em Recebido · Após o vencimento Valor + Recebido · Antes da solicitação Escolher Destinatário Dispensar Digite a pubky @@ -1563,6 +1565,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 69f0d8b2d6..4902722b59 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1289,6 +1289,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. @@ -1544,6 +1545,7 @@ Aceitar pagamento em Recebido · Após expirar Montante + Recebido · Antes do pedido Escolher destinatário Ignorar Introduza o pubky @@ -1557,6 +1559,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 57daa7442f..466f34ff2c 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1330,6 +1330,7 @@ Запрошенный доступ не соответствует этому заявлению Bitkit. Эта ссылка авторизации Pubky недействительна. В этой авторизации отсутствует обязательное заявление Bitkit. + Домашний сервер не принял этот код регистрации. Попробуйте снова или используйте другую ссылку для регистрации. Ретранслятор авторизации не ответил вовремя. Пожалуйста, попробуйте снова. Ошибка Авторизации Это заявление Bitkit не поддерживается. @@ -1564,6 +1565,7 @@ Принимать оплату в Получено · После истечения Сумма + Получено · До запроса Выберите Получателя Отклонить Введите pubky @@ -1577,6 +1579,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 c3772cdd6b..79ae2a1e5c 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. @@ -1417,6 +1418,7 @@ Accept payment in Received · After expiry Amount + Received · Before request Choose Recipient Contact Date @@ -1433,6 +1435,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/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/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"))) 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..4ea9a154cb 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,58 @@ 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 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) @@ -3666,5 +3719,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..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() @@ -1669,6 +1692,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..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 = @@ -3664,6 +3692,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..49f98c7539 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt @@ -64,12 +64,15 @@ 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 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 +691,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 +703,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 { @@ -740,29 +790,48 @@ 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(78, proof.receiptLogIndex.length) + assertEquals(BigInteger.ONE.shiftLeft(256) - BigInteger.ONE, BigInteger(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")) @@ -785,7 +854,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 +878,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/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() diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index cee385b416..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()) + 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 +2525,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 +3361,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 +3421,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 +3468,7 @@ class PaykitSdkServiceTest { emptyList() }, failedToDeliver = emptyList(), + acknowledged = emptyList(), ) } val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } 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)) + } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index fc5bb29dda..6454802bb9 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -11707,6 +11707,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() 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. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0d6250ea64..4c43ce6dfe 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" } 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 + +