diff --git a/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt b/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt index 8aabaa02bc..ee92f369a6 100644 --- a/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt +++ b/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt @@ -69,6 +69,7 @@ data class PrivatePaykitCacheData( data class PrivatePaykitContactCacheData( val remoteEndpoints: List = emptyList(), val consumedPrivatePaymentListVersion: ULong? = null, + val consumedPrivatePaymentListOwner: String? = null, val localInvoice: PrivatePaykitStoredInvoiceData? = null, val receivedInvoicePaymentHashes: List = emptyList(), val hasPublishedPrivatePaymentList: Boolean = false, diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index bbaf74a6df..0ae7112985 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -55,12 +55,18 @@ class PaykitAllowancePayer @Inject constructor( eligibleIdentifiers: List, ): Result = privatePaykitRepo.resolveAllowancePayment(request, eligibleIdentifiers) - suspend fun consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext): Result = - privatePaykitRepo.consumePrivatePaymentList(publicKey, context) + suspend fun consumePaymentList( + publicKey: String, + context: PrivatePaykitPaymentContext, + attemptId: String, + ): Result = privatePaykitRepo.consumePrivatePaymentList(publicKey, context, owner = attemptId) - /** Gives back a consumed payment list, so the request's manual fallback can still use it. */ - suspend fun releasePaymentList(publicKey: String, context: PrivatePaykitPaymentContext): Result = - privatePaykitRepo.releasePrivatePaymentList(publicKey, context) + /** Gives back a payment list the attempt consumed, so the request's manual fallback can still use it. */ + suspend fun releasePaymentList( + publicKey: String, + context: PrivatePaykitPaymentContext, + attemptId: String, + ): Result = privatePaykitRepo.releasePrivatePaymentList(publicKey, context, owner = attemptId) /** Accepts through [accept] with this install as the request's owner, so a failed payment stays payable here. */ suspend fun acceptOnThisInstall(request: PaykitPaymentRequest, accept: suspend () -> T): Result = @@ -194,10 +200,13 @@ class PaykitAllowanceExecutor @Inject constructor( updated } - private suspend fun updateLocalStateDurably( + /** Like [updateLocalState], but a failed save throws instead of being logged. */ + suspend fun updateLocalStateDurably( identity: String, change: (PaykitAllowanceLocalState) -> PaykitAllowanceLocalState, - ): Unit = stateMutex.withLock { store.save(identity, change(localState(identity))) } + ): PaykitAllowanceLocalState = stateMutex.withLock { + change(localState(identity)).also { store.save(identity, it) } + } fun isHandling(requestId: PaykitPaymentRequestId): Boolean = requestId in inFlightRequestIds @@ -241,8 +250,9 @@ class PaykitAllowanceExecutor @Inject constructor( if (current != null && !current.requiresReconciliation) return@withContext current val history = current?.history ?: AllowanceAccountingHistory(emptyList(), emptyList(), emptyList()) + val journal = localState(identity).journal.associateBy { it.attemptId } val outcomes = history.occurrences.flatMap { it.attempts }.mapNotNull { attempt -> - verifiedOutcome(attempt, identity)?.let { PaymentOutcomeReport(attempt.attemptId, it) } + verifiedOutcome(attempt, journal)?.let { PaymentOutcomeReport(attempt.attemptId, it) } } val reconciled = paykitSdkService.reconcileAllowanceAccounting( AllowanceAccountingReconciliation( @@ -270,6 +280,8 @@ class PaykitAllowanceExecutor @Inject constructor( // No ledger means nothing was ever admitted, so there is nothing to recover. paykitSdkService.allowanceAccountingState() ?: return@runSuspendCatching val state = ensureReconciled(identity) + // One read of the journal per pass: each attempt below is settled from this snapshot. + val journal = localState(identity).journal.associateBy { it.attemptId } for (attempt in state.history.occurrences.flatMap { it.attempts }) { if (attempt.attemptId in liveAttemptIds) continue when (attempt.status) { @@ -277,9 +289,9 @@ class PaykitAllowanceExecutor @Inject constructor( if (attempt.epoch == state.epoch) record(attempt.attemptId, PaymentOutcome.FAILED, identity) } PaymentExecutionStatus.SUBMITTED, PaymentExecutionStatus.UNKNOWN -> { - val outcome = verifiedOutcome(attempt, identity) + val entry = journal[attempt.attemptId] + val outcome = entry?.let { journalOutcome(it) } ?: PaymentOutcome.UNKNOWN.takeIf { attempt.status == PaymentExecutionStatus.SUBMITTED } - val entry = localState(identity).journal.firstOrNull { it.attemptId == attempt.attemptId } outcome?.let { record(attempt.attemptId, it, identity) } // One that paid nothing gives the payee's list back, as a live failure does. if (outcome == PaymentOutcome.FAILED && entry?.isAutomatic == true) { @@ -296,7 +308,14 @@ class PaykitAllowanceExecutor @Inject constructor( private suspend fun syncSettledJournal(attemptId: String, stage: Stage, identity: String) { val current = localState(identity).journal.firstOrNull { it.attemptId == attemptId } ?: return - if (current.stage == Stage.SUCCEEDED || current.stage == Stage.FAILED) return + if (current.stage == Stage.SUCCEEDED) return + if (current.stage == Stage.FAILED) { + // A failed automatic attempt still holding its list could not save the cleared claim; give it back now. + if (stage == Stage.FAILED && current.isAutomatic && current.paymentListVersion != null) { + releasePaymentList(current, identity) + } + return + } val before = applyJournalStage(attemptId, stage, identity) ?: return if (stage == Stage.FAILED && before.isAutomatic) releasePaymentList(before, identity) } @@ -339,14 +358,15 @@ class PaykitAllowanceExecutor @Inject constructor( } } - private suspend fun verifiedOutcome(attempt: PaymentAttemptRecord, identity: String): PaymentOutcome? = - when (attempt.status) { - PaymentExecutionStatus.PREPARED -> PaymentOutcome.FAILED - PaymentExecutionStatus.SUCCEEDED, PaymentExecutionStatus.FAILED -> null - PaymentExecutionStatus.SUBMITTED, PaymentExecutionStatus.UNKNOWN -> localState(identity).journal - .firstOrNull { it.attemptId == attempt.attemptId } - ?.let { journalOutcome(it) } - } + private suspend fun verifiedOutcome( + attempt: PaymentAttemptRecord, + journal: Map, + ): PaymentOutcome? = when (attempt.status) { + PaymentExecutionStatus.PREPARED -> PaymentOutcome.FAILED + PaymentExecutionStatus.SUCCEEDED, PaymentExecutionStatus.FAILED -> null + PaymentExecutionStatus.SUBMITTED, PaymentExecutionStatus.UNKNOWN -> journal[attempt.attemptId] + ?.let { journalOutcome(it) } + } private suspend fun journalOutcome(entry: JournalEntry): PaymentOutcome? = when (entry.stage) { // The journal is written before the node call, so no payment left this wallet. @@ -384,11 +404,15 @@ class PaykitAllowanceExecutor @Inject constructor( private suspend fun applyJournalStage(attemptId: String, stage: Stage, identity: String): JournalEntry? { var before: JournalEntry? = null updateLocalState(identity) { state -> - before = state.journal.firstOrNull { it.attemptId == attemptId } - state.copy(journal = state.journal.map { if (it.attemptId == attemptId) it.copy(stage = stage) else it }) + val current = state.journal.firstOrNull { it.attemptId == attemptId } + before = current + val updated = state.copy( + journal = state.journal.map { if (it.attemptId == attemptId) it.copy(stage = stage) else it }, + ) + if (current?.isPaidAutomaticallyBy(stage) == true) updated.withAutoPaid(current) else updated } val entry = before ?: return null - if (entry.isAutomatic && entry.stage != Stage.SUCCEEDED && stage == Stage.SUCCEEDED) { + if (entry.isPaidAutomaticallyBy(stage)) { val paymentId = entry.paymentHash?.lowercase() ?: entry.transactionId if (paymentId != null) { _events.tryEmit( @@ -594,13 +618,13 @@ class PaykitAllowanceExecutor @Inject constructor( throw it } - payer.consumePaymentList(request.counterparty, payment.context).onFailure { + payer.consumePaymentList(request.counterparty, payment.context, submitted.attemptId).onFailure { record(submitted.attemptId, PaymentOutcome.FAILED, identity) throw it } payer.prepareProof(request, endpointIdentifier, payment.appId, submitted.allowanceId).onFailure { payer.cancelProofPreparation(request) - failUnsent(request, payment, submitted.attemptId, identity) + failUnsent(submitted.attemptId, identity) throw it } @@ -623,13 +647,13 @@ class PaykitAllowanceExecutor @Inject constructor( payment.appId, ).onFailure { payer.cancelProofPreparation(request) - failUnsent(request, payment, attemptId, identity) + failUnsent(attemptId, identity) throw it } runSuspendCatching { setStage(Stage.SENDING, attemptId, identity) }.onFailure { payer.discardLightningProof(paymentHash) - failUnsent(request, payment, attemptId, identity) + failUnsent(attemptId, identity) throw it } val sats = amountSats.takeUnless { payment.lightningInvoiceHasAmount } @@ -644,7 +668,7 @@ class PaykitAllowanceExecutor @Inject constructor( // Only an error that proves the payment never left releases the reservation; anything else stays open, // and the node's payment event records its outcome. if (payer.failLightningPayment(paymentHash, it)) { - failUnsent(request, payment, attemptId, identity) + failUnsent(attemptId, identity) throw it } Logger.warn("Left an allowance payment with an uncertain send result to the node", it, context = TAG) @@ -810,6 +834,24 @@ class PaykitAllowanceExecutor @Inject constructor( } } + /** + * Records a released manual on-chain send of [requestId] as failed: the release spends its coins back to the wallet + * and removes the evidence recovery reads, so the attempt would otherwise stay unknown and hold capacity. Throws + * when an outcome could not be recorded, so the release is not finished without it. + */ + suspend fun onchainSendReleased(payerIdentity: String?, requestId: PaykitPaymentRequestId) { + val identity = activeIdentity?.takeIf { PubkyPublicKeyFormat.matches(it, payerIdentity) } ?: return + withContext(ioDispatcher) { + settleMutex.withLock { + val attempts = localState(identity).journal.filter { + it.requestId == requestId && !it.isAutomatic && it.paymentHash == null && + it.stage in SETTLEABLE_STAGES + } + for (attempt in attempts) record(attempt.attemptId, PaymentOutcome.FAILED, identity) + } + } + } + // endregion // region Helpers @@ -849,37 +891,11 @@ class PaykitAllowanceExecutor @Inject constructor( } } - private suspend fun failUnsent( - request: PaykitPaymentRequest, - payment: PrivatePaykitAllowancePayment, - attemptId: String, - identity: String, - ) { - // Released only by whichever of this and the node's failure event takes the journaled version first. - if (takePaymentListVersion(attemptId, identity) != null) { - payer.releasePaymentList(request.counterparty, payment.context) - } + private suspend fun failUnsent(attemptId: String, identity: String) { + releasePaymentList(attemptId, identity) record(attemptId, PaymentOutcome.FAILED, identity) } - /** - * Clears and returns the payment list version an attempt consumed, so the list is released at most once: a second - * release could clear another payment's claim on the same version. - */ - private suspend fun takePaymentListVersion(attemptId: String, identity: String): JournalEntry? { - var taken: JournalEntry? = null - updateLocalState(identity) { state -> - state.copy( - journal = state.journal.map { - if (it.attemptId != attemptId) return@map it - taken = it.takeIf { entry -> entry.paymentListVersion != null } - it.copy(paymentListVersion = null, previousPaymentListVersion = null) - }, - ) - } - return taken - } - private suspend fun updateJournalEntry( attemptId: String, identity: String, @@ -890,14 +906,27 @@ class PaykitAllowanceExecutor @Inject constructor( } } - private suspend fun releasePaymentList(entry: JournalEntry, identity: String) { - val taken = takePaymentListVersion(entry.attemptId, identity) ?: return + private suspend fun releasePaymentList(entry: JournalEntry, identity: String) = + releasePaymentList(entry.attemptId, identity) + + /** + * Gives back the payment list the attempt journaled, and clears the journaled version only once that succeeded, so + * a failed release or a restart leaves it for the next recovery. Releasing is owner-checked, so a repeated release + * never undoes another payment's claim. + */ + private suspend fun releasePaymentList(attemptId: String, identity: String) { + val entry = localState(identity).journal.firstOrNull { it.attemptId == attemptId } ?: return + val version = entry.paymentListVersion ?: return val context = PrivatePaykitPaymentContext( paymentAppsByEndpoint = emptyMap(), - paymentListVersion = requireNotNull(taken.paymentListVersion), - previousPaymentListVersion = taken.previousPaymentListVersion, + paymentListVersion = version, + previousPaymentListVersion = entry.previousPaymentListVersion, ) - payer.releasePaymentList(entry.requestId.counterparty, context) + payer.releasePaymentList(entry.requestId.counterparty, context, attemptId).onSuccess { + updateJournalEntry(attemptId, identity) { + it.copy(paymentListVersion = null, previousPaymentListVersion = null) + } + } } private suspend fun notifyLimitReached(request: PaykitPaymentRequest, identity: String) { @@ -914,6 +943,19 @@ class PaykitAllowanceExecutor @Inject constructor( private fun PaykitPaymentRequest.scope() = PaymentRequestScope(counterparty, paymentRequestId) +private fun JournalEntry.isPaidAutomaticallyBy(stage: Stage): Boolean = + isAutomatic && this.stage != Stage.SUCCEEDED && stage == Stage.SUCCEEDED + +private fun PaykitAllowanceLocalState.withAutoPaid(entry: JournalEntry): PaykitAllowanceLocalState { + // Allowance ids are unique per contact only, so payments count toward the contact's own allowance. + val key = PaykitAllowance.key(entry.requestId.counterparty, entry.allowanceId) + val total = (autoPaidSatsByAllowance[key] ?: 0uL).safe() + entry.amountSats.safe() + return copy( + autoPaidSatsByAllowance = autoPaidSatsByAllowance + (key to total), + autoPaidRequestIds = autoPaidRequestIds + entry.requestId, + ) +} + /** * Allowances pay only bitcoin-denominated requests; null for any other request. A conversion offered for other rails, * such as USDT, does not change the bitcoin amount a Lightning payment sends. When it prices a bitcoin rail too, the diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt index bf7a8329df..4d37012615 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -2,12 +2,14 @@ package to.bitkit.repositories import com.synonym.paykit.AllowanceFilter import com.synonym.paykit.AllowanceHistoryStatus +import com.synonym.paykit.AllowanceLifecycleState import com.synonym.paykit.AllowanceLocalRole import com.synonym.paykit.AllowanceRecord import com.synonym.paykit.AllowanceTerms import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.PaymentOutcome import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow @@ -34,7 +36,6 @@ import to.bitkit.utils.Logger import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock @@ -106,10 +107,13 @@ class PaykitAllowanceRepo @Inject constructor( private val publishLock = Any() private val isProcessingRequests = AtomicBoolean(false) private val isAcceptingOffers = AtomicBoolean(false) - private val updates = AtomicInteger(0) + private val updates = MutableStateFlow(0) /** True while allowances are refreshing or requests are being admitted, when coverage may still change. */ - val isUpdating: Boolean get() = updates.get() > 0 + val isUpdating: Boolean get() = updates.value > 0 + + /** [isUpdating] as a flow, so requests held while coverage was changing are presented once it settles. */ + val isUpdatingFlow: Flow = updates.map { it > 0 }.distinctUntilChanged() private val manualRequestSignatures = ConcurrentHashMap() private val _allowances = MutableStateFlow>(emptyList()) private val _localState = MutableStateFlow(PaykitAllowanceLocalState()) @@ -132,6 +136,15 @@ class PaykitAllowanceRepo @Inject constructor( val events: SharedFlow = executor.events + private val _offersToAnnounce = MutableStateFlow>(emptyList()) + private val announcedOfferKeys = ConcurrentHashMap.newKeySet() + + /** + * Offers accepted automatically that the user has not been told about yet. They stay here until + * [markOffersAnnounced], independent of the refresh that accepted them, so a cancelled refresh never loses one. + */ + val offersToAnnounce: StateFlow> = _offersToAnnounce.asStateFlow() + internal constructor( ioDispatcher: CoroutineDispatcher, paykitSdkService: PaykitSdkService, @@ -172,6 +185,8 @@ class PaykitAllowanceRepo @Inject constructor( executor.activate(normalizedIdentity) if (identityChanged) { manualRequestSignatures.clear() + _offersToAnnounce.update { emptyList() } + announcedOfferKeys.clear() executor.recover(normalizedIdentity) } refresh() @@ -182,6 +197,8 @@ class PaykitAllowanceRepo @Inject constructor( activeIdentity = null executor.activate(null) manualRequestSignatures.clear() + _offersToAnnounce.update { emptyList() } + announcedOfferKeys.clear() publish(emptyList(), PaykitAllowanceLocalState()) } @@ -212,17 +229,25 @@ class PaykitAllowanceRepo @Inject constructor( * together: a request is not shown for manual payment before the allowances it may fall under are refreshed. */ fun beginCoverageUpdate(): () -> Unit { - updates.incrementAndGet() + updates.update { it + 1 } val released = AtomicBoolean(false) - return { if (released.compareAndSet(false, true)) updates.decrementAndGet() } + return { if (released.compareAndSet(false, true)) updates.update { it - 1 } } } /** - * True when an allowance this wallet grants, in any state, names [request]'s contact: such a request may become - * covered by the refresh in progress, so it is not shown for manual payment meanwhile. + * True when an allowance this wallet grants, or was asked to grant, names [request]'s contact and can still pay: + * such a request may become covered by the refresh in progress, so it is not shown for manual payment meanwhile. */ - fun mayCover(request: PaykitPaymentRequest): Boolean = request.requiresAcceptance && _allowances.value.any { - it.isAllower && PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) + fun mayCover(request: PaykitPaymentRequest): Boolean { + if (!request.requiresAcceptance || !request.acceptsLightning()) return false + val now = clock.now() + return _allowances.value.any { + it.isAllower && PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) && + ( + it.lifecycleState == AllowanceLifecycleState.PROPOSED || + it.status(now) == PaykitAllowance.Status.ACTIVE + ) + } } private suspend fun listAllowances(): Result> = runSuspendCatching { @@ -281,10 +306,12 @@ class PaykitAllowanceRepo @Inject constructor( } /** - * Accepts the offers that make this wallet the allowee and returns the ones now active so the caller can tell the - * user. A failed accept stays unanswered and is tried again on the next refresh. Stops accepting once - * [canContinue] turns false and still returns what it accepted, since an active allowance is never announced - * later; returns nothing to announce once the identity it started with is gone or [canAnnounce] turns false. + * Accepts the offers that make this wallet the allowee, adds the ones now active to [offersToAnnounce] and returns + * them. A failed accept stays unanswered and is tried again on the next refresh. Stops accepting once + * [canContinue] turns false and still announces what it accepted. An offer is marked before its accept and stays + * marked until [markOffersAnnounced], so one whose accept committed in a call that was cancelled, or a process + * that died, is announced by the next call. Announces nothing once the identity it started with is gone or + * [canAnnounce] turns false. */ suspend fun acceptOffersFromAllowers( canAnnounce: () -> Boolean = { true }, @@ -296,23 +323,81 @@ class PaykitAllowanceRepo @Inject constructor( val answerable = entry.allowances.filter { it.isAnswerable } answerable.isNotEmpty() && answerable.all { it.isOfferFromAllower } } - if (offers.isEmpty() || !isAcceptingOffers.compareAndSet(false, true)) return@withContext emptyList() + val unannounced = unannouncedOffers(identity) + if (offers.isEmpty() && unannounced.isEmpty()) return@withContext emptyList() + if (!isAcceptingOffers.compareAndSet(false, true)) return@withContext emptyList() try { - val accepted = mutableListOf() + val accepted = unannounced.toMutableList() for (offer in offers) { if (!isCurrent() || !canContinue()) break - // Answers the entry that was checked, so an ask that joined it since is never accepted here. - respond(offer) { acceptProposal(it) } - .onSuccess { accepted += offer } - .onFailure { Logger.warn("Failed to accept an allowance offer", it, context = TAG) } + val keys = offer.allowances.map { it.key } + // The mark must be saved before the accept, or an accept that commits as the call is cancelled is lost. + val isMarked = runSuspendCatching { + executor.updateLocalStateDurably(identity) { + it.copy(acceptingOfferIds = it.acceptingOfferIds + keys) + } + }.onFailure { Logger.warn("Failed to mark an allowance offer before accepting it", it, context = TAG) } + .isSuccess + if (isMarked) { + respond(offer) { acceptProposal(it) } + .onSuccess { accepted += offer } + .onFailure { Logger.warn("Failed to accept an allowance offer", it, context = TAG) } + } + } + if (!isCurrent()) return@withContext emptyList() + _offersToAnnounce.update { queued -> + val keys = queuedOfferKeys() + queued + accepted.filter { offer -> offer.allowances.none { it.key in keys } } } - if (isCurrent()) accepted else emptyList() + accepted } finally { isAcceptingOffers.set(false) } } + /** Clears offers the user was told about, so they are never announced again. */ + suspend fun markOffersAnnounced(offers: List) { + val announced = offers.flatMap { offer -> offer.allowances.map { it.key } }.toSet() + if (announced.isEmpty()) return + // Kept in memory too, so a mark whose clear fails to save is not announced again on the next poll. + announcedOfferKeys += announced + // The marks go before the queued offers: a refresh in between still sees one of them and never queues it again. + activeIdentity?.let { identity -> + withContext(ioDispatcher) { + executor.updateLocalState(identity) { it.copy(acceptingOfferIds = it.acceptingOfferIds - announced) } + } + } + _offersToAnnounce.update { pending -> + pending.filterNot { entry -> entry.allowances.any { it.key in announced } } + } + } + + private fun queuedOfferKeys(): Set = + _offersToAnnounce.value.flatMap { entry -> entry.allowances.map { it.key } }.toSet() + + private suspend fun unannouncedOffers(identity: String): List { + // Read before the marks, the reverse of the order markOffersAnnounced clears them in. + val pending = queuedOfferKeys() + val stored = executor.localState(identity).acceptingOfferIds + val marked = stored - announcedOfferKeys + val stale = _entries.value.flatMap { it.allowances } + .filter { + it.key in marked && it.lifecycleState != AllowanceLifecycleState.PROPOSED && + it.lifecycleState != AllowanceLifecycleState.ACCEPTED + } + .map { it.key } + .toSet() + (stored intersect announcedOfferKeys) + if (stale.isNotEmpty()) { + executor.updateLocalState(identity) { it.copy(acceptingOfferIds = it.acceptingOfferIds - stale) } + } + if (marked.isEmpty()) return emptyList() + return _entries.value.filter { entry -> + entry.allowances.none { it.key in pending } && + entry.allowances.any { it.key in marked && it.lifecycleState == AllowanceLifecycleState.ACCEPTED } + } + } + suspend fun reject(entryId: String): Result = respond(entry(entryId)) { paykitSdkService.rejectAllowance(it.counterparty, it.allowanceId) } @@ -391,6 +476,8 @@ class PaykitAllowanceRepo @Inject constructor( fun coversRequest(request: PaykitPaymentRequest): Boolean { // Only bitcoin requests are paid automatically; anything else follows the manual flow at once. if (request.allowanceSats() == null) return false + // Allowances pay only over Lightning, so a request that accepts no Lightning invoice never waits for one. + if (!request.acceptsLightning()) return false val now = clock.now() return _allowances.value.any { allowance -> allowance.isAllower && @@ -556,15 +643,10 @@ class PaykitAllowanceRepo @Inject constructor( limits = state.groups.firstOrNull { it.id == id }?.limits, ) } - val paid = state.journal.filter { it.isAutomatic && it.stage == PaykitAllowanceLocalState.Stage.SUCCEEDED } - // Allowance ids are unique per contact only, so payments count toward the contact's own allowance. - val paidByAllowance = paid.groupBy { PaykitAllowance.key(it.requestId.counterparty, it.allowanceId) } - .mapValues { (_, payments) -> - payments.fold(0uL) { total, payment -> total.safe() + payment.amountSats.safe() } - } + val paidByAllowance = state.autoPaidSatsByAllowance _allowances.update { allowances } _localState.update { state } - _autoPaidRequestIds.update { paid.mapTo(mutableSetOf()) { it.requestId } } + _autoPaidRequestIds.update { state.autoPaidRequestIds } _entries.update { entries } _autoPaidSats.update { entries.associate { entry -> @@ -575,3 +657,5 @@ class PaykitAllowanceRepo @Inject constructor( } } } + +private fun PaykitPaymentRequest.acceptsLightning() = MethodId.Bolt11.rawValue in acceptedPaymentEndpointIdentifiers diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt index 6b8a331f9c..92a9f62c87 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt @@ -20,8 +20,14 @@ data class PaykitAllowanceLocalState( val journal: List = emptyList(), /** [PaykitAllowance.key]s of received proposals already shown. */ val presentedProposalIds: Set = emptySet(), + /** [PaykitAllowance.key]s of offers being accepted, announced once their allowance is accepted. */ + val acceptingOfferIds: Set = emptySet(), val notifiedRequestIds: Set = emptySet(), val lastTrustedTimeMillis: Long? = null, + /** Sats paid automatically per [PaykitAllowance.key], kept apart from the journal, which is trimmed. */ + val autoPaidSatsByAllowance: Map = emptyMap(), + /** Requests paid automatically, kept apart from the journal for the "Auto-paid" tag. */ + val autoPaidRequestIds: Set = emptySet(), ) { @Serializable data class Group( diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 739e88f308..a07a0520e6 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -124,6 +124,7 @@ class PaykitPaymentProofRepo @Inject constructor( private val store: PaykitPaymentProofStore, private val hwWalletRepo: HwWalletRepo, private val privatePaykitRepo: Lazy, + private val allowanceExecutor: Lazy, ) { companion object { private const val TAG = "PaykitPaymentProofRepo" @@ -588,6 +589,9 @@ class PaykitPaymentProofRepo @Inject constructor( /** Removes the original proof, then the attempt, once its release reached the network. */ private suspend fun finishRelease(attempt: OnchainSendAttempt) { + // The release removes what allowance recovery reads, so the request's ledger attempt is failed first; a + // failure leaves the release unfinished, to be finished again later. + attempt.requestId?.let { allowanceExecutor.get().onchainSendReleased(attempt.payerIdentity, it) } val removeOriginalProof: suspend (OnchainSendAttempt) -> Boolean = { if (it.isBlocktankFunding) true else removeOriginalShopProofs(it) } if (!lightningRepo.abandonUnbroadcastOnchainSend(attempt, removeOriginalProof)) { diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index f307fc2f22..2727795527 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -16,6 +16,7 @@ import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport import com.synonym.paykit.PubkyIdentityCapability +import dagger.Lazy import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -330,6 +331,7 @@ class PaykitPaymentRequestRepo @Inject constructor( private val lightningRepo: LightningRepo, private val currencyRepo: CurrencyRepo, private val usdtRepo: UsdtRepo, + private val allowanceRepo: Lazy, ) { companion object { private const val TAG = "PaykitPaymentRequestRepo" @@ -1819,14 +1821,21 @@ class PaykitPaymentRequestRepo @Inject constructor( } private suspend fun synchronizeAfterSubscriptionAction(identity: String) { - runSuspendCatching { synchronizeLocked(stateGeneration.get(), identity) } - .onFailure { - Logger.warn( - "Failed to refresh Paykit subscriptions after a committed action", - it, - context = TAG, - ) - } + // This intake can bring an allowance's acceptance with a new request from its contact, so it is held too. + val releaseCoverage = allowanceRepo.get().beginCoverageUpdate() + try { + runSuspendCatching { synchronizeLocked(stateGeneration.get(), identity) } + .onSuccess { allowanceRepo.get().refresh() } + .onFailure { + Logger.warn( + "Failed to refresh Paykit subscriptions after a committed action", + it, + context = TAG, + ) + } + } finally { + releaseCoverage() + } } private suspend fun applySubscriptionRecordLocked(record: PaymentRequestRecord, now: Instant) { diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt index 49984ab725..6f24036e58 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt @@ -36,6 +36,7 @@ internal data class PrivatePaykitState( internal data class ContactState( var remoteEndpoints: List = emptyList(), var consumedPrivatePaymentListVersion: ULong? = null, + var consumedPrivatePaymentListOwner: String? = null, var localInvoice: StoredInvoice? = null, var receivedInvoicePaymentHashes: List = emptyList(), var hasPublishedPrivatePaymentList: Boolean = false, @@ -43,6 +44,7 @@ internal data class ContactState( constructor(cache: PrivatePaykitContactCacheData) : this( remoteEndpoints = cache.remoteEndpoints.map { StoredPaymentEntry(it.methodId, it.endpointData) }, consumedPrivatePaymentListVersion = cache.consumedPrivatePaymentListVersion, + consumedPrivatePaymentListOwner = cache.consumedPrivatePaymentListOwner, localInvoice = cache.localInvoice?.let { invoice -> StoredInvoice(invoice.bolt11, invoice.paymentHash, invoice.expiresAt) }, @@ -60,6 +62,7 @@ internal data class ContactState( fun cacheState() = PrivatePaykitContactCacheData( remoteEndpoints = remoteEndpoints.map { PrivatePaykitStoredPaymentEntryData(it.methodId, it.endpointData) }, consumedPrivatePaymentListVersion = consumedPrivatePaymentListVersion, + consumedPrivatePaymentListOwner = consumedPrivatePaymentListOwner, localInvoice = localInvoice?.let { invoice -> PrivatePaykitStoredInvoiceData(invoice.bolt11, invoice.paymentHash, invoice.expiresAt) }, diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 02dc4fbb55..ec6558f323 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -116,6 +116,7 @@ class PrivatePaykitRepo @Inject constructor( } private val publicationMutex = Mutex() + private val claimMutex = Mutex() private val serializedDispatcher = ioDispatcher.limitedParallelism(1) private val retryScope = appScope(serializedDispatcher, TAG) private val isContactPreparationActive = MutableStateFlow(false) @@ -655,31 +656,44 @@ class PrivatePaykitRepo @Inject constructor( suspend fun consumePrivatePaymentList( publicKey: String, context: PrivatePaykitPaymentContext, + ): Result = consumePrivatePaymentList(publicKey, context, owner = null) + + /** Consumes the payment list for [owner], so only a release by the same owner gives it back. */ + suspend fun consumePrivatePaymentList( + publicKey: String, + context: PrivatePaykitPaymentContext, + owner: String?, ): Result = withContext(serializedDispatcher) { - runSuspendCatching { - val normalizedKey = normalizedPublicKey(publicKey) ?: throw PrivatePaykitError.InvalidPublicKey - val paymentListVersion = context.paymentListVersion ?: return@runSuspendCatching - val contactState = ensureState().contacts.getOrPut(normalizedKey) { ContactState() } - val consumedVersion = contactState.consumedPrivatePaymentListVersion - if (consumedVersion != null && paymentListVersion <= consumedVersion) { - throw PrivatePaykitError.PaymentListAlreadyConsumed - } + // The claim is reset before it is persisted; a release or consume must not see it until the write settles. + claimMutex.withLock { + runSuspendCatching { + val normalizedKey = normalizedPublicKey(publicKey) ?: throw PrivatePaykitError.InvalidPublicKey + val paymentListVersion = context.paymentListVersion ?: return@runSuspendCatching + val contactState = ensureState().contacts.getOrPut(normalizedKey) { ContactState() } + val consumedVersion = contactState.consumedPrivatePaymentListVersion + if (consumedVersion != null && paymentListVersion <= consumedVersion) { + throw PrivatePaykitError.PaymentListAlreadyConsumed + } - val previousEndpoints = contactState.remoteEndpoints - contactState.consumedPrivatePaymentListVersion = paymentListVersion - contactState.remoteEndpoints = emptyList() - try { - persistState(markWalletBackup = true) - } catch (error: Throwable) { - contactState.consumedPrivatePaymentListVersion = consumedVersion - contactState.remoteEndpoints = previousEndpoints - throw error + val previousEndpoints = contactState.remoteEndpoints + val previousOwner = contactState.consumedPrivatePaymentListOwner + contactState.consumedPrivatePaymentListVersion = paymentListVersion + contactState.consumedPrivatePaymentListOwner = owner + contactState.remoteEndpoints = emptyList() + try { + persistState(markWalletBackup = true) + } catch (error: Throwable) { + contactState.consumedPrivatePaymentListVersion = consumedVersion + contactState.consumedPrivatePaymentListOwner = previousOwner + contactState.remoteEndpoints = previousEndpoints + throw error + } + Logger.info( + "Consumed private Paykit payment list version $paymentListVersion " + + "for '${redacted(normalizedKey)}'", + context = TAG, + ) } - Logger.info( - "Consumed private Paykit payment list version $paymentListVersion " + - "for '${redacted(normalizedKey)}'", - context = TAG, - ) } }.onFailure { Logger.warn("Failed to consume private Paykit payment details", it, context = TAG) @@ -688,39 +702,55 @@ class PrivatePaykitRepo @Inject constructor( suspend fun releasePrivatePaymentList( publicKey: String, context: PrivatePaykitPaymentContext, + ): Result = releasePrivatePaymentList(publicKey, context, owner = null) + + suspend fun releasePrivatePaymentList( + publicKey: String, + context: PrivatePaykitPaymentContext, + owner: String?, ): Result = releasePrivatePaymentListVersion( publicKey, context.paymentListVersion, context.previousPaymentListVersion, + owner, ) + /** Gives back the payment list only when [owner] consumed it, so a release never undoes another payment's claim. */ suspend fun releasePrivatePaymentListVersion( publicKey: String, paymentListVersion: ULong?, previousPaymentListVersion: ULong? = null, + owner: String? = null, ): Result = withContext(serializedDispatcher) { - runSuspendCatching { - val normalizedKey = normalizedPublicKey(publicKey) ?: throw PrivatePaykitError.InvalidPublicKey - if (paymentListVersion == null) return@runSuspendCatching - val contactState = ensureState().contacts[normalizedKey] ?: return@runSuspendCatching - val consumedVersion = contactState.consumedPrivatePaymentListVersion - if (consumedVersion != paymentListVersion) return@runSuspendCatching - - if (previousPaymentListVersion != null && previousPaymentListVersion >= paymentListVersion) { - throw PaykitPaymentRequestError.RequestUnavailable - } - contactState.consumedPrivatePaymentListVersion = previousPaymentListVersion - try { - persistState(markWalletBackup = true) - } catch (error: Throwable) { - contactState.consumedPrivatePaymentListVersion = consumedVersion - throw error + // The claim is reset before it is persisted; a release or consume must not see it until the write settles. + claimMutex.withLock { + runSuspendCatching { + val normalizedKey = normalizedPublicKey(publicKey) ?: throw PrivatePaykitError.InvalidPublicKey + if (paymentListVersion == null) return@runSuspendCatching + val contactState = ensureState().contacts[normalizedKey] ?: return@runSuspendCatching + val consumedVersion = contactState.consumedPrivatePaymentListVersion + if (consumedVersion != paymentListVersion) return@runSuspendCatching + val consumedOwner = contactState.consumedPrivatePaymentListOwner + if (consumedOwner != owner) return@runSuspendCatching + + if (previousPaymentListVersion != null && previousPaymentListVersion >= paymentListVersion) { + throw PaykitPaymentRequestError.RequestUnavailable + } + contactState.consumedPrivatePaymentListVersion = previousPaymentListVersion + contactState.consumedPrivatePaymentListOwner = null + try { + persistState(markWalletBackup = true) + } catch (error: Throwable) { + contactState.consumedPrivatePaymentListVersion = consumedVersion + contactState.consumedPrivatePaymentListOwner = consumedOwner + throw error + } + Logger.info( + "Released private Paykit payment list version '$paymentListVersion' " + + "for '${redacted(normalizedKey)}'", + context = TAG, + ) } - Logger.info( - "Released private Paykit payment list version '$paymentListVersion' " + - "for '${redacted(normalizedKey)}'", - context = TAG, - ) } }.onFailure { Logger.warn("Failed to release private Paykit payment details", it, context = TAG) diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt index fc91ca97dc..0a8c9cbc6a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt @@ -166,6 +166,7 @@ fun AllowanceSheet( useBiometrics = it.useBiometrics, onCancel = onAuthorizationCancel, onVerified = viewModel::onAuthorized, + pinDescriptionRes = R.string.security__pin_allowance, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPinCheckScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPinCheckScreen.kt index 35a9096750..ef8eac345b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPinCheckScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPinCheckScreen.kt @@ -1,6 +1,7 @@ package to.bitkit.ui.screens.wallets.send import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -44,6 +45,7 @@ const val PIN_CHECK_RESULT_KEY = "PIN_CHECK_RESULT_KEY" fun SendPinCheckScreen( onBack: () -> Unit, onSuccess: () -> Unit, + @StringRes descriptionRes: Int = R.string.security__pin_send, ) { val app = appViewModel ?: return val attemptsRemaining by app.pinAttemptsRemaining.collectAsStateWithLifecycle() @@ -62,6 +64,7 @@ fun SendPinCheckScreen( PinCheckContent( pin = pin, + description = stringResource(descriptionRes), attemptsRemaining = attemptsRemaining, onKeyPress = { key -> if (key == KEY_DELETE) { @@ -80,6 +83,7 @@ fun SendPinCheckScreen( @Composable private fun PinCheckContent( pin: String, + description: String, attemptsRemaining: Int, onKeyPress: (String) -> Unit, onBack: () -> Unit, @@ -103,7 +107,7 @@ private fun PinCheckContent( horizontalAlignment = Alignment.CenterHorizontally, ) { BodyM( - text = stringResource(R.string.security__pin_send), + text = description, color = Colors.White64, modifier = Modifier.padding(horizontal = 32.dp) ) @@ -159,6 +163,7 @@ private fun Preview() { BottomSheetPreview { PinCheckContent( pin = "123", + description = stringResource(R.string.security__pin_send), attemptsRemaining = 8, onKeyPress = {}, onBack = {}, @@ -176,6 +181,7 @@ private fun PreviewAttemptsLeft() { BottomSheetPreview { PinCheckContent( pin = "123", + description = stringResource(R.string.security__pin_send), attemptsRemaining = 3, onKeyPress = {}, onBack = {}, @@ -193,6 +199,7 @@ private fun PreviewAttemptsLast() { BottomSheetPreview { PinCheckContent( pin = "123", + description = stringResource(R.string.security__pin_send), attemptsRemaining = 1, onKeyPress = {}, onBack = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt index a870d99519..d8901164d2 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt @@ -1,6 +1,7 @@ package to.bitkit.ui.screens.wallets.usdt import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -446,13 +447,18 @@ internal fun UsdtRefreshEffect( } @Composable -internal fun UsdtAuthentication(useBiometrics: Boolean, onCancel: () -> Unit, onVerified: () -> Unit) { +internal fun UsdtAuthentication( + useBiometrics: Boolean, + onCancel: () -> Unit, + onVerified: () -> Unit, + @StringRes pinDescriptionRes: Int = R.string.security__pin_send, +) { val supported = rememberBiometricAuthSupported() var fallbackToPin by remember { mutableStateOf(false) } if (useBiometrics && supported && !fallbackToPin) { BiometricsView(onSuccess = onVerified, onFailure = { fallbackToPin = true }) } else { - SendPinCheckScreen(onBack = onCancel, onSuccess = onVerified) + SendPinCheckScreen(onBack = onCancel, onSuccess = onVerified, descriptionRes = pinDescriptionRes) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 38613f0993..e70308e635 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4,6 +4,7 @@ package to.bitkit.viewmodels import android.content.Context import android.content.Intent +import android.icu.text.ListFormatter import android.net.Uri import android.nfc.NfcAdapter import androidx.annotation.StringRes @@ -1196,16 +1197,25 @@ class AppViewModel @Inject constructor( releaseCoverage() } return result.onSuccess { - val acceptedOffers = paykitAllowanceRepo.acceptOffersFromAllowers( + paykitAllowanceRepo.acceptOffersFromAllowers( canAnnounce = { !privatePaykitRepo.isProfileDeletionInProgress.value }, canContinue = { !(onlyWhileActive && isPaymentRequestPollingStopped.value) }, ) - announceAcceptedAllowanceOffers(acceptedOffers) - if (processAllowanceRequests()) paykitPaymentRequestRepo.refresh() + if (processAllowanceRequests()) refreshRequestsUnderCoverageHold() presentNextIncomingPaykitPaymentRequest() } } + private suspend fun refreshRequestsUnderCoverageHold() { + // An intake after automatic payments can bring an allowance change too, so it runs under the coverage hold. + val releaseCoverage = paykitAllowanceRepo.beginCoverageUpdate() + try { + paykitPaymentRequestRepo.refresh().onSuccess { paykitAllowanceRepo.refresh() } + } finally { + releaseCoverage() + } + } + /** Automatic payments hold background Paykit work back like a manual payment submission does. */ private suspend fun processAllowanceRequests(): Boolean { val finishSubmission = beginPaymentSubmission() @@ -1227,6 +1237,21 @@ class AppViewModel @Inject constructor( viewModelScope.launch { paykitAllowanceRepo.events.collect(::handlePaykitAllowanceEvent) } + viewModelScope.launch { + // Announced here rather than by the refresh that accepted them, which polling may cancel. + paykitAllowanceRepo.offersToAnnounce.filter { it.isNotEmpty() }.collect { offers -> + announceAcceptedAllowanceOffers(offers) + paykitAllowanceRepo.markOffersAnnounced(offers) + } + } + viewModelScope.launch { + // A request held while allowances updated is presented once they settle, whether or not the refresh + // succeeded, including one the user tapped. + paykitAllowanceRepo.isUpdatingFlow.drop(1).filter { !it }.collect { + val isCurrent = PubkyPublicKeyFormat.matches(paymentRequestIdentity, pubkyRepo.publicKey.value) + if (isPaykitEnabled.value && isCurrent) presentNextIncomingPaykitPaymentRequest() + } + } } private fun handlePaykitAllowanceEvent(event: PaykitAllowanceEvent) { @@ -1258,15 +1283,19 @@ class AppViewModel @Inject constructor( } private fun announceAcceptedAllowanceOffers(offers: List) { - offers.forEach { offer -> - notifyAllowanceEvent( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.subscriptions__allowance_offer_accepted_title), - description = context.getString(R.string.subscriptions__allowance_offer_accepted_description) - .replace("{name}", allowanceContactName(offer.counterparty)), - testTag = "AllowanceOfferAcceptedToast", - ) - } + // Several offers accepted in one refresh are announced together, so no toast replaces another. + val description = allowanceOffersAcceptedDescription( + names = offers.map { it.counterparty }.distinct().map(::allowanceContactName), + single = context.getString(R.string.subscriptions__allowance_offer_accepted_description), + several = context.getString(R.string.subscriptions__allowance_offers_accepted_description), + formatNames = { ListFormatter.getInstance().format(it) }, + ) ?: return + notifyAllowanceEvent( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.subscriptions__allowance_offer_accepted_title), + description = description, + testTag = "AllowanceOfferAcceptedToast", + ) } /** @@ -8575,3 +8604,20 @@ internal fun resolvePastedPubkyRoute( return Routes.AddContact(normalizedKey) } + +/** + * One "Allowance Added" description for every offer accepted at once, or null when there is none; [formatNames] joins + * several names as the locale lists them. + */ +internal fun allowanceOffersAcceptedDescription( + names: List, + single: String, + several: String, + formatNames: (List) -> String, +): String? { + return when (names.size) { + 0 -> null + 1 -> single.replace("{name}", names.single()) + else -> several.replace("{names}", formatNames(names)) + } +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 459ee338cc..2b5ac0ebba 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -436,6 +436,7 @@ <accent>عبارة المرور:</accent> {passphrase} أضفت عبارة مرور إلى عبارة الاسترداد أثناء إعداد المحفظة. عبارة المرور الخاصة بك + يرجى إدخال رمز PIN لتأكيد هذا التغيير في الميزانية. {attemptsRemaining} محاولات متبقية. نسيت رمز PIN؟ اختر رمز PIN من 4 أرقام يرجى استخدام رمز PIN ستتذكره. إذا نسيت رمز PIN، يمكنك إعادة تعيينه، لكن ذلك سيتطلب استعادة محفظتك. @@ -1411,6 +1412,7 @@ تمت إضافة الميزانية ستدفع محفظة {name} طلباتك ضمن هذه الحدود دون السؤال في كل مرة. يمكن لأي منكما إنهاؤها. {name} يعرض عليك\n<accent>ميزانية</accent> + أعدّ لك {names} ميزانيات تم دفع {amount} تلقائيًا مدفوع تلقائيًا يتم دفع هذا الطلب تلقائيًا بالفعل. 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 efc9e1a454..5c5572d43e 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -457,6 +457,7 @@ <accent>Frase de acceso:</accent> {passphrase} Ha añadido una frase de acceso a su frase de recuperación durante la configuración. Su frase de acceso + Por favor, introduzca su código PIN para confirmar este cambio en la asignación. {attemptsRemaining} intentos restantes. ¿Ha olvidado su PIN? Elija el PIN de 4 dígitos Por favor, utilice un PIN que recuerde. Si olvida su PIN puede restablecerlo, pero para ello tendrá que restaurar su billetera. @@ -1426,6 +1427,7 @@ Asignación agregada La billetera de {name} pagará sus solicitudes dentro de estos límites sin preguntar cada vez. Cualquiera de los dos puede finalizarla. {name} le ofrece\n<accent>una asignación</accent> + {names} configuraron asignaciones para usted {amount} pagado automáticamente Pagado automáticamente Esta solicitud ya se está pagando automáticamente. diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 78aa507505..ca8d6f35ae 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -451,6 +451,7 @@ <accent>Frase de pas:</accent> {passphrase} Has afegit una frase de pas a la teva frase de recuperació durant la configuració de la cartera. La teva frase de pas + Si us plau, introdueix el teu codi PIN per confirmar aquest canvi en l\'assignació. {attemptsRemaining} intents restants. Has oblidat el PIN? Escolliu un PIN de 4 dígits Si us plau, utilitza un PIN que recordis. Si oblides el teu PIN pots restablir-lo, però caldrà restaurar la cartera. @@ -1423,6 +1424,7 @@ Assignació afegida La cartera de {name} pagarà les teves sol·licituds dins d\'aquests límits sense preguntar cada vegada. Qualsevol de vosaltres hi pot posar fi. {name} t\'ofereix\n<accent>una assignació</accent> + {names} t\'han configurat assignacions {amount} pagat automàticament Pagat automàticament Aquesta sol·licitud ja s\'està pagant automàticament. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 3f071da34e..dc2df4e4fc 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -456,6 +456,7 @@ <accent>Přístupová fráze:</accent> {passphrase} Během nastavení peněženky jste do fráze pro obnovení přidali přístupovou frázi. Vaše přístupová fráze + Pro potvrzení této změny rozpočtu prosím zadejte svůj PIN kód. Zbývá {attemptsRemaining} pokus/ů. Zapomněli jste svůj PIN? Vyberte 4místný PIN Použijte prosím PIN, který si zapamatujete. Pokud svůj PIN zapomenete, můžete jej resetovat, ale to bude vyžadovat obnovení vaší peněženky. @@ -1425,6 +1426,7 @@ Rozpočet přidán Peněženka uživatele {name} bude platit vaše žádosti v rámci těchto limitů, aniž by se pokaždé ptala. Kterákoli strana jej může ukončit. {name} nabízí\n<accent>rozpočet</accent> + {names} vám nastavili rozpočty {amount} zaplaceno automaticky Zaplaceno automaticky Tato žádost se již platí automaticky. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ee496be8fa..bd408bdbb6 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -452,6 +452,7 @@ PIN deaktivieren Bitte gib deinen PIN-Code ein Letzter Versuch. Wenn du den falschen PIN erneut eingibst, wird dein Wallet zurückgesetzt. + Bitte gib deinen PIN-Code ein, um diese Änderung des Budgets zu bestätigen. {attemptsRemaining} Versuche verbleiben. Hast du deinen PIN vergessen? PIN vergessen? PIN vergessen? Setze hier dein Bitkit-Wallet zurück und stelle es mit deiner Wiederherstellungsphrase wieder her. Vergebe eine neue PIN nach deiner Wiederherstellung. @@ -1424,6 +1425,7 @@ Budget hinzugefügt Die Wallet von {name} bezahlt deine Anforderungen innerhalb dieser Limits, ohne jedes Mal nachzufragen. Ihr könnt es beide beenden. {name} bietet dir\n<accent>ein Budget</accent> + {names} haben dir Budgets eingerichtet {amount} automatisch bezahlt Automatisch bezahlt Diese Anforderung wird bereits automatisch bezahlt. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index e8046092f2..7ebec42040 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -436,6 +436,7 @@ <accent>Κωδική φράση:</accent> {passphrase} Πρόσθεσες μια κωδική φράση στη φράση ανάκτησής σου κατά τη ρύθμιση του πορτοφολιού. Η κωδική φράση σου + Εισάγαγε τον κωδικό PIN για να επιβεβαιώσεις αυτή την αλλαγή στον προϋπολογισμό. {attemptsRemaining} προσπάθειες απομένουν. Ξέχασες το PIN; Επίλεξε 4ψήφιο PIN Χρησιμοποίησε ένα PIN που θα θυμάσαι. Αν ξεχάσεις το PIN σου μπορείς να το επαναφέρεις, αλλά αυτό θα απαιτήσει επαναφορά του πορτοφολιού σου. @@ -1417,6 +1418,7 @@ Ο προϋπολογισμός προστέθηκε Το πορτοφόλι της επαφής {name} θα πληρώνει τα αιτήματά σου εντός αυτών των ορίων χωρίς να ρωτά κάθε φορά. Οποιοσδήποτε από τους δύο μπορεί να τον τερματίσει. {name} σού προσφέρει\n<accent>έναν προϋπολογισμό</accent> + {names} σού όρισαν προϋπολογισμούς {amount} πληρώθηκαν αυτόματα Πληρώθηκαν αυτόματα Αυτό το αίτημα πληρώνεται ήδη αυτόματα. diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index c63aa9c6ac..7946a0a77d 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -454,6 +454,7 @@ <accent>Contraseña:</accent> {passphrase} Añadiste una passphrase a tu frase de recuperación al configurar tu wallet. Su frase de acceso + Por favor, introduzca su código PIN para confirmar este cambio en la asignación. Quedan {attemptsRemaining} intentos. ¿Olvidó su PIN? Elija un PIN de 4 dígitos Por favor, utilice un PIN que recuerde. Si olvida su PIN puede restablecerlo, pero para ello tendrá que restaurar su monedero. @@ -1421,6 +1422,7 @@ Asignación añadida El monedero de {name} pagará tus solicitudes dentro de estos límites sin preguntar cada vez. Cualquiera de los dos puede finalizarla. {name} te ofrece\n<accent>una asignación</accent> + {names} te han configurado asignaciones {amount} pagado automáticamente Pagado automáticamente Esta solicitud ya se está pagando automáticamente. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5a17e4a1eb..a425c9fc8e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -449,6 +449,7 @@ Desactivar PIN Por favor, introduzca su código PIN Último intento. Si vuelve a introducir un PIN erróneo, se reiniciará su monedero. + Por favor, introduzca su código PIN para confirmar este cambio en la asignación. Quedan {attemptsRemaining} intentos. ¿Olvidó su PIN? ¿Ha olvidado el PIN? ¿Ha olvidado su PIN? Reinicie y recupere su monedero Bitkit con su frase de recuperación. Establezca un nuevo PIN después de completar la recuperación. @@ -1421,6 +1422,7 @@ Asignación añadida El monedero de {name} pagará sus solicitudes dentro de estos límites sin preguntar cada vez. Cualquiera de los dos puede finalizarla. {name} le ofrece\n<accent>una asignación</accent> + {names} configuraron asignaciones para usted {amount} pagado automáticamente Pagado automáticamente Esta solicitud ya se está pagando automáticamente. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 5c62af16be..a3acc79a7f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -452,6 +452,7 @@ Désactiver le code PIN Veuillez saisir votre code PIN Dernière tentative. Si vous saisissez à nouveau un code PIN erroné, votre portefeuille sera réinitialisé. + Veuillez saisir votre code PIN pour confirmer cette modification de l\'allocation. {attemptsRemaining} tentatives restantes. Vous avez oublié votre code PIN ? Vous avez oublié votre code PIN ? Vous avez oublié votre code PIN ? Réinitialisez et récupérez votre portefeuille Bitkit avec votre phrase de récupération. Définissez un nouveau code PIN après avoir terminé la récupération. @@ -1422,6 +1423,7 @@ Allocation ajoutée Le portefeuille de {name} paiera vos demandes dans ces limites sans demander à chaque fois. Chacun de vous peut y mettre fin. {name} vous offre\n<accent>une allocation</accent> + {names} vous ont configuré des allocations {amount} payé automatiquement Payé automatiquement Cette demande est déjà en cours de paiement automatique. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 32d7629534..f06999db75 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -453,6 +453,7 @@ <accent>Passphrase:</accent> {passphrase} Hai aggiunto una passphrase alla tua recovery phrase durante la configurazione del wallet. La Tua Passphrase + Inserisci il codice PIN per confermare questa modifica al budget. {attemptsRemaining} tentativi rimanenti. Ha dimenticato il PIN? Scegli il PIN a 4 cifre Utilizza un PIN che ricorderai. Se dimentichi il PIN, puoi reimpostarlo, ma ciò richiede il ripristino del wallet. @@ -1425,6 +1426,7 @@ Budget Aggiunto Il wallet di {name} pagherà le tue richieste entro questi limiti senza chiedere ogni volta. Ognuno di voi può terminarlo. {name} ti offre\n<accent>un budget</accent> + {names} hanno impostato dei budget per te {amount} pagati automaticamente Pagato automaticamente Questa richiesta è già in fase di pagamento automatico. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index a9c5dc3685..01248a3640 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -451,6 +451,7 @@ <accent>Wachtwoordzin:</accent> {passphrase} Je hebt een wachtwoordzin toegevoegd aan je herstelzin tijdens het instellen van de wallet. Je wachtwoordzin + Voer je pincode in om deze wijziging van de toelage te bevestigen. {attemptsRemaining} pogingen over. Pincode vergeten? Kies 4-cijferige pincode Gebruik een pincode die je zult onthouden. Als je je pincode vergeet, kun je deze resetten, maar daarvoor moet je je wallet herstellen. @@ -1423,6 +1424,7 @@ Toelage toegevoegd De wallet van {name} betaalt uw verzoeken binnen deze limieten zonder elke keer te vragen. Beide partijen kunnen de toelage beëindigen. {name} biedt\n<accent>een toelage</accent> aan + {names} hebben een toelage voor u ingesteld {amount} automatisch betaald Automatisch betaald Dit verzoek wordt al automatisch betaald. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index e4caffd391..4b05d99a4f 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -455,6 +455,7 @@ Wyłącz kod PIN Podaj proszę kod PIN Ostatnia próba. Wpisanie błędnego kodu spowoduje zresetowanie portfela. + Proszę podaj swój kod PIN, aby potwierdzić tę zmianę budżetu. Liczba pozostałych prób:{attemptsRemaining}. Zapomniałeś kodu PIN? Zapomniałeś kodu PIN? Zapomniałeś kodu PIN? Zresetuj i odzyskaj swój portfel Bitkit używając mnemonika. Ustaw nowy kod PIN po zakończeniu odzyskiwania portfela. @@ -1424,6 +1425,7 @@ Budżet dodany Portfel {name} będzie opłacać Twoje żądania w ramach tych limitów bez pytania za każdym razem. Każda ze stron może go zakończyć. {name} oferuje\n<accent>budżet</accent> + {names} ustawili dla Ciebie budżety {amount} opłacono automatycznie Opłacono automatycznie To żądanie jest już opłacane automatycznie. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 1837fcf3b9..76fae7535a 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -458,6 +458,7 @@ <gray>Senha:</gray> {passphrase} Você adicionou uma frase secreta durante a configuração da carteira. Sua Frase Secreta + Digite seu código PIN para confirmar essa alteração na mesada. {attemptsRemaining} tentativas restantes. Esqueceu seu PIN? Escolha o PIN de 4 dígitos Use um PIN que você se lembrará. Se você esquecer o PIN, poderá redefini-lo mas isso exigirá a restauração da carteira. @@ -1425,6 +1426,7 @@ Mesada Adicionada A carteira de {name} pagará suas solicitações dentro destes limites sem perguntar a cada vez. Qualquer um de vocês pode encerrá-la. {name} oferece\n<accent>uma mesada</accent> + {names} configuraram mesadas para você {amount} pago automaticamente Pago automaticamente Esta solicitação já está sendo paga automaticamente. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 7b1ee65fea..46314fdf35 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -433,6 +433,7 @@ Desativar PIN Por favor, digite seu código PIN Última tentativa. Se digitar o PIN errado novamente, sua carteira será redefinida. + Digite seu código PIN para confirmar essa alteração na mesada. {attemptsRemaining} tentativas restantes. Esqueceu seu PIN? Esqueceu o PIN? Esqueceu seu PIN? Redefina e recupere sua carteira Bitkit com a sua frase de recuperação. Defina um novo PIN após concluir a recuperação. @@ -1411,6 +1412,7 @@ Mesada adicionada A carteira de {name} pagará os seus pedidos dentro destes limites sem perguntar de cada vez. Qualquer uma das partes pode terminá-la. {name} oferece\n<accent>uma mesada</accent> + {names} configuraram mesadas para si {amount} pago automaticamente Pago automaticamente Este pedido já está a ser pago automaticamente. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c80dea25c0..55e571cc9d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -469,6 +469,7 @@ <accent>Кодовая фраза:</accent> {passphrase} Вы добавили кодовую фразу к фразе восстановления во время создания кошелька. Ваша Кодовая Фраза + Введите PIN-код, чтобы подтвердить это изменение бюджета. Осталось {attemptsRemaining} попыток. Забыли PIN-код? Выберите 4-значный PIN-код Пожалуйста, используйте PIN-код, который вы запомните. Если вы забудете свой PIN-код, вы можете сбросить его, но для этого потребуется восстановить кошелёк. @@ -1440,6 +1441,7 @@ Бюджет Добавлен Кошелёк {name} будет оплачивать ваши запросы в пределах этих лимитов, не спрашивая каждый раз. Любая из сторон может его завершить. {name} предлагает\n<accent>бюджет</accent> + {names} настроили для вас бюджеты {amount} оплачено автоматически Оплачено автоматически Этот запрос уже оплачивается автоматически. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 735b541868..55bc1c9e72 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -740,6 +740,7 @@ <accent>Passphrase:</accent> {passphrase} You added a passphrase to your recovery phrase during wallet setup. Your Passphrase + Please enter your PIN code to confirm this allowance change. {attemptsRemaining} attempts remaining. Forgot your PIN? Choose 4-Digit PIN Please use a PIN you will remember. If you forget your PIN you can reset it, but that will require restoring your wallet. @@ -1050,6 +1051,7 @@ Allowance Added {name}\'s wallet will pay your requests within these limits without asking each time. Either of you can end it. an allowance]]> + {names} set up allowances for you {amount} paid automatically Paid automatically This request is already being paid automatically. diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt index 5e53522ca9..ce58b43580 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -34,6 +34,7 @@ import org.junit.Test import org.lightningdevkit.ldknode.PaymentStatus import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder @@ -219,8 +220,8 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { whenever(payer.acceptOnThisInstall(any(), any())).doSuspendableAnswer { runCatching { it.getArgument AllowanceAssociationRecord>(1).invoke() } } - whenever(payer.consumePaymentList(any(), any())).thenReturn(Result.success(Unit)) - whenever(payer.releasePaymentList(any(), any())).thenReturn(Result.success(Unit)) + whenever(payer.consumePaymentList(any(), any(), any())).thenReturn(Result.success(Unit)) + whenever(payer.releasePaymentList(any(), any(), any())).thenReturn(Result.success(Unit)) whenever(payer.prepareProof(any(), any(), any(), anyOrNull())).thenReturn(Result.success(Unit)) whenever(payer.associateLightningPayment(any(), any(), any(), any())).thenReturn(Result.success(Unit)) whenever(payer.payLightning(any(), anyOrNull(), anyOrNull(), any())).thenReturn(Result.success(PAYMENT_HASH)) @@ -263,7 +264,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { order.verify(sdk).reserveAutomaticPayment(any(), any(), any()) order.verify(sdk).receivePrivateMessages(request.counterparty) order.verify(sdk).beginPaymentExecution(eq(AUTOMATIC_ATTEMPT_ID), any()) - order.verify(payer).consumePaymentList(request.counterparty, lightningPayment().context) + order.verify(payer).consumePaymentList(request.counterparty, lightningPayment().context, AUTOMATIC_ATTEMPT_ID) order.verify(payer).prepareProof(request, fixtures.lightningIdentifier, PAYMENT_APP_ID, ALLOWANCE_ID) order.verify(payer).associateLightningPayment( request, @@ -499,7 +500,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) assertEquals(listOf(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.FAILED)), recordedOutcomes) - verify(payer, never()).consumePaymentList(any(), any()) + verify(payer, never()).consumePaymentList(any(), any(), any()) verify(payer, never()).payLightning(any(), anyOrNull(), anyOrNull(), any()) } @@ -512,7 +513,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) assertEquals(listOf(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.FAILED)), recordedOutcomes) assertEquals(listOf(Stage.FAILED), sut.localState(identity).journal.map { it.stage }) - verify(payer, never()).consumePaymentList(any(), any()) + verify(payer, never()).consumePaymentList(any(), any(), any()) verify(payer, never()).prepareProof(any(), any(), any(), anyOrNull()) verify(payer, never()).payLightning(any(), anyOrNull(), anyOrNull(), any()) } @@ -527,7 +528,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) verify(payer).cancelProofPreparation(request) - verify(payer).releasePaymentList(eq(request.counterparty), any()) + verify(payer).releasePaymentList(eq(request.counterparty), any(), any()) assertEquals(listOf(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.FAILED)), recordedOutcomes) verify(payer, never()).payLightning(any(), anyOrNull(), anyOrNull(), any()) } @@ -541,7 +542,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) assertEquals(listOf(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.FAILED)), recordedOutcomes) verify(sdk, never()).beginPaymentExecution(any(), any()) - verify(payer, never()).consumePaymentList(any(), any()) + verify(payer, never()).consumePaymentList(any(), any(), any()) verify(payer, never()).payLightning(any(), anyOrNull(), anyOrNull(), any()) } @@ -554,7 +555,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) assertEquals(listOf(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.FAILED)), recordedOutcomes) verify(payer).discardLightningProof(PAYMENT_HASH) - verify(payer).releasePaymentList(any(), any()) + verify(payer).releasePaymentList(any(), any(), any()) verify(payer, never()).payLightning(any(), anyOrNull(), anyOrNull(), any()) } @@ -579,20 +580,159 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { } @Test - fun `a payment list given back by a failed send is not given back again by the node's failure event`() = test { + fun `a payment list given back by a failed send stays given back after the node's failure event`() = test { sut.activate(identity) whenever( payer.payLightning(any(), anyOrNull(), anyOrNull(), any()) ).thenReturn(Result.failure(TestAllowanceError("send"))) whenever(payer.failLightningPayment(any(), any())).thenReturn(true) + // The node's failure event lands during the failed send's release; a repeated release is owner-checked. + var isSettling = false + whenever(payer.releasePaymentList(any(), any(), any())).doSuspendableAnswer { + if (!isSettling) { + isSettling = true + sut.lightningPaymentSettled(PAYMENT_HASH, succeeded = false) + } + Result.success(Unit) + } + sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + verify(payer, atLeastOnce()).releasePaymentList(any(), any(), eq(AUTOMATIC_ATTEMPT_ID)) + assertNull(sut.localState(identity).journal.single().paymentListVersion) + } + + @Test + fun `a payment list whose cleared version cannot be saved stays journaled for recovery`() = test { + sut.activate(identity) + seedJournal( + journalEntry("attempt-1", fixtures.paymentRequest(), Stage.SENT, paymentHash = PAYMENT_HASH) + .copy(paymentListVersion = 4uL, previousPaymentListVersion = 3uL), + ) + failSave = { true } + sut.lightningPaymentSettled(PAYMENT_HASH, succeeded = false) - verify(payer, times(1)).releasePaymentList(any(), any()) + verify(payer, times(1)).releasePaymentList(any(), any(), any()) + assertEquals(4uL, sut.localState(identity).journal.single().paymentListVersion) + } + + @Test + fun `a payment list whose cleared claim could not be saved is given back by the next recovery`() = test { + sut.activate(identity) + seedJournal( + journalEntry("attempt-1", fixtures.paymentRequest(), Stage.SENT, paymentHash = PAYMENT_HASH) + .copy(paymentListVersion = 4uL, previousPaymentListVersion = 3uL), + ) + var saves = 0 + // The stage save succeeds; only the claim clear that follows it fails. + failSave = { ++saves == 2 } + + sut.lightningPaymentSettled(PAYMENT_HASH, succeeded = false) + + assertEquals(listOf(Stage.FAILED), sut.localState(identity).journal.map { it.stage }) + assertEquals(4uL, sut.localState(identity).journal.single().paymentListVersion) + + failSave = { false } + accountingState = fixtures.accountingState( + occurrences = listOf(occurrence("attempt-1", PaymentExecutionStatus.FAILED)), + ) + sut.recover(identity) + + verify(payer, times(2)).releasePaymentList(any(), any(), any()) assertNull(sut.localState(identity).journal.single().paymentListVersion) } + @Test + fun `recovery gives back a list a failed attempt never consumed only as that attempt`() = test { + beginDecision = PaymentAttemptDecision.Blocked(AllowanceAccountingBlock.ManualOnly) + sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + verify(payer, never()).consumePaymentList(any(), any(), any()) + + accountingState = fixtures.accountingState( + occurrences = listOf(occurrence(AUTOMATIC_ATTEMPT_ID, PaymentExecutionStatus.FAILED)), + ) + sut.recover(identity) + + // The claim belongs to whoever consumed the list since, so this owner's release changes nothing. + verify(payer, times(1)).releasePaymentList(any(), any(), eq(AUTOMATIC_ATTEMPT_ID)) + } + + @Test + fun `a payment list whose release fails is given back by the next recovery`() = test { + val request = fixtures.paymentRequest() + accountingState = fixtures.accountingState( + occurrences = listOf(occurrence("consumed", PaymentExecutionStatus.SUBMITTED)), + ) + seedJournal(journalEntry("consumed", request, Stage.SUBMITTED).copy(paymentListVersion = 4uL)) + whenever(payer.releasePaymentList(any(), any(), any())) + .thenReturn(Result.failure(TestAllowanceError("release"))) + + sut.recover(identity) + + assertEquals(4uL, sut.localState(identity).journal.single().paymentListVersion) + + whenever(payer.releasePaymentList(any(), any(), any())).thenReturn(Result.success(Unit)) + accountingState = fixtures.accountingState( + occurrences = listOf(occurrence("consumed", PaymentExecutionStatus.FAILED)), + ) + sut.recover(identity) + + verify(payer, times(2)).releasePaymentList(any(), any(), eq("consumed")) + assertNull(sut.localState(identity).journal.single().paymentListVersion) + } + + @Test + fun `recovery failing an attempt while its settlement records it gives the payment list back once`() = test { + sut.activate(identity) + accountingState = fixtures.accountingState( + occurrences = listOf(occurrence("attempt-1", PaymentExecutionStatus.SUBMITTED)), + ) + seedJournal( + journalEntry("attempt-1", fixtures.paymentRequest(), Stage.SENT, paymentHash = PAYMENT_HASH) + .copy(paymentListVersion = 4uL), + ) + nodeStatuses[PAYMENT_HASH] = PaymentStatus.FAILED + var isRecovering = false + whenever(sdk.recordPaymentOutcome(any())).doSuspendableAnswer { + recordedOutcomes += it.getArgument(0) + if (!isRecovering) { + isRecovering = true + sut.recover(identity) + } + fixtures.attemptRecord("attempt-1", status = PaymentExecutionStatus.FAILED) + } + + sut.lightningPaymentSettled(PAYMENT_HASH, succeeded = false) + + verify(payer, times(1)).releasePaymentList(any(), any(), any()) + } + + @Test + fun `a settlement during recovery's node lookup gives the payment list back once`() = test { + sut.activate(identity) + accountingState = fixtures.accountingState( + occurrences = listOf(occurrence("attempt-1", PaymentExecutionStatus.SUBMITTED)), + ) + seedJournal( + journalEntry("attempt-1", fixtures.paymentRequest(), Stage.SENT, paymentHash = PAYMENT_HASH) + .copy(paymentListVersion = 4uL), + ) + var isSettling = false + whenever(payer.lightningPaymentStatus(PAYMENT_HASH)).doSuspendableAnswer { + if (!isSettling) { + isSettling = true + sut.lightningPaymentSettled(PAYMENT_HASH, succeeded = false) + } + PaymentStatus.FAILED + } + + sut.recover(identity) + + verify(payer, times(1)).releasePaymentList(any(), any(), any()) + } + @Test fun `a payment list the node's failure event gave back during the send is not given back again`() = test { sut.activate(identity) @@ -604,7 +744,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) - verify(payer, times(1)).releasePaymentList(any(), any()) + verify(payer, times(1)).releasePaymentList(any(), any(), any()) } @Test @@ -616,7 +756,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any()) verify(sdk, never()).reserveAutomaticPayment(any(), any(), any()) - verify(payer, never()).consumePaymentList(any(), any()) + verify(payer, never()).consumePaymentList(any(), any(), any()) } @Test @@ -652,6 +792,34 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { verify(payer, never()).payLightning(any(), anyOrNull(), anyOrNull(), any()) } + @Test + fun `an automatic payment keeps its lasting total once the journal trims it`() = test { + sut.activate(identity) + val request = fixtures.paymentRequest() + seedJournal(journalEntry("attempt-1", request, Stage.SENT, paymentHash = PAYMENT_HASH)) + sut.lightningPaymentSettled(PAYMENT_HASH, succeeded = true) + sut.lightningPaymentSettled(PAYMENT_HASH, succeeded = true) + + var manualAttempts = 0 + whenever(sdk.reserveManualPayment(any(), any())).doSuspendableAnswer { + PaymentAttemptDecision.Ready( + fixtures.attemptRecord( + "manual-${manualAttempts++}", + mode = PaymentExecutionMode.MANUAL, + allowanceId = null, + status = PaymentExecutionStatus.PREPARED, + ), + ) + } + repeat(200) { sut.beginManualPayment(fixtures.paymentRequest(id = "manual-$it"), fixtures.onchainIdentifier) } + + val state = sut.localState(identity) + assertTrue(state.journal.none { it.attemptId == "attempt-1" }, "The journal keeps only its newest entries") + val key = PaykitAllowance.key(request.counterparty, ALLOWANCE_ID) + assertEquals(mapOf(key to 1_000uL), state.autoPaidSatsByAllowance) + assertEquals(setOf(request.id), state.autoPaidRequestIds) + } + @Test fun `a failed automatic lightning payment gives the payee's payment list back`() = test { sut.activate(identity) @@ -667,6 +835,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { verify(payer).releasePaymentList( eq(request.counterparty), eq(PrivatePaykitPaymentContext(emptyMap(), paymentListVersion = 4uL, previousPaymentListVersion = 3uL)), + any(), ) assertTrue(sut.isAutomaticPayment(PAYMENT_HASH.uppercase())) assertFalse(sut.isAutomaticPayment("f".repeat(64))) @@ -742,6 +911,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { verify(payer).releasePaymentList( eq(request.counterparty), eq(PrivatePaykitPaymentContext(emptyMap(), paymentListVersion = 4uL)), + any(), ) } @@ -783,6 +953,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { verify(payer).releasePaymentList( eq(request.counterparty), eq(PrivatePaykitPaymentContext(emptyMap(), paymentListVersion = 5uL)), + any(), ) } @@ -933,6 +1104,23 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { ) } + @Test + fun `a released manual on-chain payment is recorded failed`() = test { + sut.activate(identity) + val request = fixtures.paymentRequest() + val attemptId = checkNotNull(sut.beginManualPayment(request, fixtures.onchainIdentifier).getOrThrow()) + sut.finishManualPayment(attemptId, PaymentOutcome.UNKNOWN, transactionId = "c".repeat(64)) + recordedOutcomes.clear() + + sut.onchainSendReleased(fixtures.otherCounterpartyKey, request.id) + assertEquals(emptyList(), recordedOutcomes, "Another identity's release changes nothing") + + sut.onchainSendReleased(identity, request.id) + + assertEquals(listOf(PaymentOutcomeReport(attemptId, PaymentOutcome.FAILED)), recordedOutcomes) + assertEquals(Stage.FAILED, sut.localState(identity).journal.single().stage) + } + @Test fun `manual payment fails when the request is already recorded`() = test { sut.activate(identity) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt index b101c6ff1e..994d3e26bf 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt @@ -8,6 +8,7 @@ import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.OutboundPrivateSendReport import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -36,6 +37,7 @@ import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.SubscriptionClockOffset import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull @@ -66,6 +68,7 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { } private val proposedTerms = mutableListOf>>() private var localState = PaykitAllowanceLocalState() + private var failDurableSave = false private var records = listOf() private lateinit var sut: PaykitAllowanceRepo @@ -78,6 +81,12 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { localState = change(localState) localState } + whenever(executor.updateLocalStateDurably(any(), any())).doSuspendableAnswer { + if (failDurableSave) throw PaykitAllowanceError.Unavailable + val change = it.getArgument<(PaykitAllowanceLocalState) -> PaykitAllowanceLocalState>(1) + localState = change(localState) + localState + } whenever(executor.autoPay(any(), any(), any())).thenReturn(PaykitAllowanceAutoPayResult.STARTED) whenever(executor.isHandling(any())).thenReturn(false) whenever(executor.classifyAboveLimit(any(), any(), any())).thenReturn(false) @@ -152,27 +161,22 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { } @Test - fun `auto-paid sats and requests come from succeeded automatic payments`() = test { + fun `auto-paid sats and requests come from the lasting totals, not the trimmed journal`() = test { records = listOf(fixtures.record(allowanceId = ALLOWANCE_ID)) val paid = fixtures.paymentRequest(id = "paid") - val failed = fixtures.paymentRequest(id = "failed") - val secondPaid = fixtures.paymentRequest(id = "second") + val trimmedAway = fixtures.paymentRequest(id = "journaled") localState = PaykitAllowanceLocalState( groups = listOf(group(ALLOWANCE_ID)), - journal = listOf( - journalEntry("a", paid, ALLOWANCE_ID, 1_000uL, Stage.SUCCEEDED), - journalEntry("b", secondPaid, ALLOWANCE_ID, 2_000uL, Stage.SUCCEEDED), - journalEntry("c", failed, ALLOWANCE_ID, 5_000uL, Stage.FAILED), - journalEntry("d", fixtures.paymentRequest(id = "manual"), null, 7_000uL, Stage.SUCCEEDED, false), - ), + journal = listOf(journalEntry("b", trimmedAway, ALLOWANCE_ID, 2_000uL, Stage.SUCCEEDED)), + autoPaidSatsByAllowance = mapOf(entryKey to 3_000uL), + autoPaidRequestIds = setOf(paid.id), ) sut.activate(identity) assertEquals(mapOf("group-1" to 3_000uL), sut.autoPaidSats.value) assertTrue(sut.isAutoPaid(paid.id)) - assertFalse(sut.isAutoPaid(failed.id)) - assertFalse(sut.isAutoPaid(fixtures.paymentRequest(id = "manual").id)) + assertFalse(sut.isAutoPaid(trimmedAway.id)) } @Test @@ -181,14 +185,11 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { fixtures.record(), fixtures.record(counterparty = fixtures.otherCounterpartyKey), ) - val otherPaid = fixtures.paymentRequest(id = "other-paid", counterparty = fixtures.otherCounterpartyKey) - localState = PaykitAllowanceLocalState( - journal = listOf(journalEntry("a", otherPaid, ALLOWANCE_ID, 1_000uL, Stage.SUCCEEDED)), - ) + val otherKey = "${PubkyPublicKeyFormat.normalized(fixtures.otherCounterpartyKey)}/$ALLOWANCE_ID" + localState = PaykitAllowanceLocalState(autoPaidSatsByAllowance = mapOf(otherKey to 1_000uL)) sut.activate(identity) - val otherKey = "${PubkyPublicKeyFormat.normalized(fixtures.otherCounterpartyKey)}/$ALLOWANCE_ID" assertEquals(mapOf(entryKey to 0uL, otherKey to 1_000uL), sut.autoPaidSats.value) } @@ -215,6 +216,20 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { assertFalse(sut.coversRequest(fixtures.paymentRequest())) } + @Test + fun `a bitcoin request payable only over USDT is never held for an allowance`() = test { + records = listOf(fixtures.record()) + sut.activate(identity) + val usdtOnly = fixtures.paymentRequest().copy( + acceptedPaymentEndpointIdentifiers = listOf(MethodId.UsdtArbitrum.rawValue), + ) + + assertFalse(sut.coversRequest(usdtOnly)) + assertFalse(sut.isAutomaticallyHandling(usdtOnly)) + assertFalse(sut.processIncomingRequests(listOf(usdtOnly))) + verify(executor, never()).classifyAboveLimit(any(), any(), any()) + } + @Test fun `coverage ignores the subscription clock offset`() = test { SubscriptionClockOffset.setOffsetDays(400) @@ -356,6 +371,7 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { assertTrue(entry.canEnd, "The allowee can still end it") assertNull(sut.proposalForPresentation()) + sut.markOffersAnnounced(accepted) assertTrue(sut.acceptOffersFromAllowers().isEmpty(), "An accepted offer is not accepted or announced twice") verify(sdk, times(1)).acceptAllowance(any(), any(), any()) } @@ -371,7 +387,9 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { assertNull(sut.proposalForPresentation(), "A failed offer still never opens the review sheet") acceptFlipsRecordToActive() - assertEquals(listOf(fixtures.counterpartyKey), sut.acceptOffersFromAllowers().map { it.counterparty }) + val accepted = sut.acceptOffersFromAllowers() + assertEquals(listOf(fixtures.counterpartyKey), accepted.map { it.counterparty }) + sut.markOffersAnnounced(accepted) assertTrue(sut.acceptOffersFromAllowers().isEmpty()) verify(sdk, times(2)).acceptAllowance(any(), any(), any()) } @@ -408,6 +426,85 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { verify(sdk, times(1)).acceptAllowance(any(), any(), any()) } + @Test + fun `offer whose accept committed as its refresh was cancelled is announced once on the next refresh`() = test { + records = listOf(receivedProposal()) + whenever(sdk.acceptAllowance(any(), any(), any())).thenAnswer { + records = listOf(fixtures.record(localRole = AllowanceLocalRole.ALLOWEE, proposedByMe = false)) + throw CancellationException("The app left the foreground") + } + sut.activate(identity) + + assertFailsWith { sut.acceptOffersFromAllowers() } + sut.refresh() + + val unannounced = sut.acceptOffersFromAllowers() + assertEquals(listOf(fixtures.counterpartyKey), unannounced.map { it.counterparty }) + assertEquals(unannounced, sut.offersToAnnounce.value, "It waits for the app to announce it") + assertTrue(sut.acceptOffersFromAllowers().isEmpty(), "An overlapping refresh never adds it twice") + sut.markOffersAnnounced(unannounced) + assertTrue(sut.offersToAnnounce.value.isEmpty()) + assertTrue(localState.acceptingOfferIds.isEmpty(), "Its mark is cleared with it") + assertTrue(sut.acceptOffersFromAllowers().isEmpty()) + assertTrue(sut.acceptOffersFromAllowers().isEmpty(), "It is announced once") + assertTrue(localState.acceptingOfferIds.isEmpty()) + verify(sdk, times(1)).acceptAllowance(any(), any(), any()) + } + + @Test + fun `a refresh while an announced offer's mark is cleared never queues it again`() = test { + records = listOf(receivedProposal()) + acceptFlipsRecordToActive() + sut.activate(identity) + val announced = sut.acceptOffersFromAllowers() + var duringClear: List? = null + var clearing = false + whenever(executor.updateLocalState(any(), any())).doSuspendableAnswer { + val change = it.getArgument<(PaykitAllowanceLocalState) -> PaykitAllowanceLocalState>(1) + if (!clearing) { + clearing = true + duringClear = sut.acceptOffersFromAllowers() + } + localState = change(localState) + localState + } + + sut.markOffersAnnounced(announced) + + assertEquals(emptyList(), duringClear) + assertTrue(sut.offersToAnnounce.value.isEmpty()) + } + + @Test + fun `an announced offer whose cleared mark could not be saved is not announced again`() = test { + records = listOf(receivedProposal()) + acceptFlipsRecordToActive() + sut.activate(identity) + val announced = sut.acceptOffersFromAllowers() + assertEquals(listOf(fixtures.counterpartyKey), announced.map { it.counterparty }) + // updateLocalState logs a failed save and returns the changed state without storing it. + whenever(executor.updateLocalState(any(), any())).doSuspendableAnswer { + val change = it.getArgument<(PaykitAllowanceLocalState) -> PaykitAllowanceLocalState>(1) + change(localState) + } + + sut.markOffersAnnounced(announced) + + assertTrue(sut.acceptOffersFromAllowers().isEmpty(), "An offer already announced is not announced again") + } + + @Test + fun `offer is not accepted while its mark cannot be saved`() = test { + records = listOf(receivedProposal()) + acceptFlipsRecordToActive() + sut.activate(identity) + failDurableSave = true + + assertTrue(sut.acceptOffersFromAllowers().isEmpty()) + + verify(sdk, never()).acceptAllowance(any(), any(), any()) + } + @Test fun `offer accepted while the profile is being deleted is not announced`() = test { records = listOf(receivedProposal()) @@ -422,7 +519,7 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { } @Test - fun `offer accept never answers an ask that joined the entry after the check`() = test { + fun `offer accept never answers another contact's ask under the same allowance id`() = test { val secondAllower = "pubky" + "a".repeat(52) val asker = "pubky" + "m".repeat(52) val offerX = fixtures.record( @@ -642,6 +739,17 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { assertFalse(sut.isUpdating) } + @Test + fun `an allowance that can no longer pay never holds a request back`() = test { + records = listOf(fixtures.record(state = AllowanceLifecycleState.ENDED)) + sut.activate(identity) + assertFalse(sut.mayCover(fixtures.paymentRequest())) + + records = listOf(fixtures.record()) + sut.refresh() + assertTrue(sut.mayCover(fixtures.paymentRequest())) + } + @Test fun `a request above the limits is never held back while channels reconnect`() = test { records = listOf(fixtures.record()) @@ -755,11 +863,7 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { fun `automatic payment is attributed to the contact's activity`() = test { records = listOf(fixtures.record()) sut.activate(identity) - localState = PaykitAllowanceLocalState( - journal = listOf( - journalEntry("a", fixtures.paymentRequest(), ALLOWANCE_ID, 1_000uL, Stage.SUCCEEDED), - ), - ) + localState = PaykitAllowanceLocalState(autoPaidSatsByAllowance = mapOf(entryKey to 1_000uL)) executorEvents.emit(PaykitAllowanceEvent.PaidAutomatically(fixtures.counterpartyKey, 1_000uL, TXID)) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index ed471127ca..c6f146bb78 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -116,6 +116,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { private val store = mock() private val hwWalletRepo = mock() private val privatePaykitRepo = mock() + private val allowanceExecutor = mock() private var storedProofs = emptyList() private var shouldFailNextLoad = false private var shouldFailNextSave = false @@ -3121,6 +3122,25 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(1, wallet.broadcasts) assertTrue(storedProofs.isEmpty()) } + val requestId = requireNotNull(unknownShopAttempt("ab".repeat(32)).requestId) + verify(allowanceExecutor, times(3)).onchainSendReleased(LOCAL_IDENTITY, requestId) + } + + @Test + fun `release stays unfinished while its allowance outcome cannot be recorded`() = test { + val attempt = unknownShopAttempt("ab".repeat(32)) + val proof = startedOnchainProof(attempt) + storedProofs = listOf(proof) + whenever(paykitSdkService.paymentRequests()) + .thenReturn(listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.CANCELED))) + stubAbandon(attempt) + whenever(allowanceExecutor.onchainSendReleased(any(), any())).thenThrow(RuntimeException("ledger")) + + val result = paymentProofRepo().abandonUnbroadcastOnchainPayment(attempt, FakeReleaseWallet()) {} + + assertTrue(result.isFailure) + assertEquals(listOf(proof), storedProofs) + verify(lightningRepo, never()).abandonUnbroadcastOnchainSend(any(), any()) } @Test @@ -3254,7 +3274,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { { OnchainSendOutcome.Unknown(it) }, ) for (outcome in outcomes) { - reset(lightningRepo) + reset(lightningRepo, allowanceExecutor) val attempt = unknownShopAttempt("ab".repeat(32)) val proof = startedOnchainProof(attempt) storedProofs = listOf(proof) @@ -3266,6 +3286,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(paymentProofRepo().abandonUnbroadcastOnchainPayment(attempt, wallet) {}.isFailure) assertEquals(listOf(proof), storedProofs) verify(lightningRepo, never()).abandonUnbroadcastOnchainSend(any(), any()) + verify(allowanceExecutor, never()).onchainSendReleased(any(), any()) // Once a sync puts the release in the wallet, a later cancel finishes without another release. val pending = attempt.copy(releaseApproved = true, releaseTxid = wallet.releaseTxid) @@ -3523,6 +3544,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { store = store, hwWalletRepo = hwWalletRepo, privatePaykitRepo = dagger.Lazy { privatePaykitRepo }, + allowanceExecutor = dagger.Lazy { allowanceExecutor }, ) private fun unknownShopAttempt(txid: String) = acceptedAttempt( diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index 7ef2ca1a57..ee28a27799 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -84,6 +84,15 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat } private val paykitSdkService = mock() + private var coverageReleases = 0 + private val noCoverageRelease: () -> Unit = { coverageReleases++ } + private val allowanceRepo = mock { + on { beginCoverageUpdate() } doReturn noCoverageRelease + } + + init { + whenever { allowanceRepo.refresh() }.thenReturn(Result.success(Unit)) + } private val settingsStore = mock() private val presentationStore = mock() private val diagnostics = mock() @@ -152,6 +161,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat mock { on { canReceive() }.thenReturn(true) }, mock { on { currencyState }.thenReturn(MutableStateFlow(CurrencyState())) }, mock { on { state }.thenReturn(MutableStateFlow(UsdtWalletState())) }, + { allowanceRepo }, ) sut.activate(LOCAL_IDENTITY) } @@ -715,6 +725,10 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat assertEquals(Instant.parse("2027-01-01T08:00:00Z"), dueRequest?.billingPeriod?.startsAt) assertEquals(listOf(COUNTERPARTY), sut.eligibleTargets.value.map { it.publicKey }) + // The intake after the acceptance runs under the coverage hold and refreshes the allowances. + verify(allowanceRepo).beginCoverageUpdate() + verifyBlocking(allowanceRepo) { refresh() } + assertEquals(1, coverageReleases) verifyBlocking(presentationStore) { saveSubscriptionState( eq(LOCAL_IDENTITY), diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 6d306c5da1..9368885609 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -233,6 +233,14 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } private val paykitSdkService = mock() + private val noCoverageRelease: () -> Unit = {} + private val allowanceRepo = mock { + on { beginCoverageUpdate() } doReturn noCoverageRelease + } + + init { + whenever { allowanceRepo.refresh() }.thenReturn(Result.success(Unit)) + } private val settingsStore = mock() private val lightningRepo = mock() private val presentationStore = mock() @@ -301,6 +309,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { lightningRepo, currencyRepoWithRate(), mock { on { state }.thenReturn(MutableStateFlow(UsdtWalletState())) }, + { allowanceRepo }, ) sut.activate(LOCAL_IDENTITY) } @@ -355,6 +364,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { lightningRepo, currencyRepoWithRate(), mock { on { state }.thenReturn(MutableStateFlow(UsdtWalletState())) }, + { allowanceRepo }, ) sut.activate(LOCAL_IDENTITY) } @@ -2139,6 +2149,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { lightningRepo, mock(), mock { on { state }.thenReturn(MutableStateFlow(UsdtWalletState())) }, + { allowanceRepo }, ) other.activate(LOCAL_IDENTITY) var record = paymentRequestRecord() diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 5e0aa48fea..38c97c78c0 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -3700,6 +3700,51 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(6uL, cacheData.value.contacts.getValue(CONTACT_KEY).consumedPrivatePaymentListVersion) } + @Test + fun `releasePrivatePaymentList gives a list back only to the owner that consumed it`() = test { + val context = PrivatePaykitPaymentContext(emptyMap(), 7uL) + fun consumed() = cacheData.value.contacts[CONTACT_KEY]?.consumedPrivatePaymentListVersion + + sut.consumePrivatePaymentList(CONTACT_KEY, context, owner = "attempt-b").getOrThrow() + sut.releasePrivatePaymentList(CONTACT_KEY, context, owner = "attempt-a").getOrThrow() + sut.releasePrivatePaymentList(CONTACT_KEY, context).getOrThrow() + assertEquals(7uL, consumed()) + + sut.releasePrivatePaymentList(CONTACT_KEY, context, owner = "attempt-b").getOrThrow() + assertNull(consumed()) + assertNull(cacheData.value.contacts[CONTACT_KEY]?.consumedPrivatePaymentListOwner) + + sut.consumePrivatePaymentList(CONTACT_KEY, context).getOrThrow() + sut.releasePrivatePaymentList(CONTACT_KEY, context, owner = "attempt-a").getOrThrow() + assertEquals(7uL, consumed()) + } + + @Test + fun `a release overlapping another release's failed write still gives the list back`() = test { + val context = PrivatePaykitPaymentContext(emptyMap(), 7uL) + sut.consumePrivatePaymentList(CONTACT_KEY, context, owner = "attempt-a").getOrThrow() + val firstWrite = CompletableDeferred() + var writes = 0 + whenever { cacheStore.update(any()) }.doSuspendableAnswer { + if (++writes == 1) { + firstWrite.await() + throw AppError("write") + } + val transform = it.getArgument<(PrivatePaykitCacheData) -> PrivatePaykitCacheData>(0) + cacheData.value = transform(cacheData.value) + } + + val first = async { sut.releasePrivatePaymentList(CONTACT_KEY, context, owner = "attempt-a") } + runCurrent() + val second = async { sut.releasePrivatePaymentList(CONTACT_KEY, context, owner = "attempt-a") } + runCurrent() + firstWrite.complete(Unit) + + assertTrue(first.await().isFailure) + assertTrue(second.await().isSuccess) + assertNull(cacheData.value.contacts[CONTACT_KEY]?.consumedPrivatePaymentListVersion) + } + @Test fun `releasePrivatePaymentList makes matching version reusable without clearing newer consumption`() = test { val releasedContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL) diff --git a/app/src/test/java/to/bitkit/viewmodels/AllowanceOffersAcceptedDescriptionTest.kt b/app/src/test/java/to/bitkit/viewmodels/AllowanceOffersAcceptedDescriptionTest.kt new file mode 100644 index 0000000000..8d871630fc --- /dev/null +++ b/app/src/test/java/to/bitkit/viewmodels/AllowanceOffersAcceptedDescriptionTest.kt @@ -0,0 +1,34 @@ +package to.bitkit.viewmodels + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class AllowanceOffersAcceptedDescriptionTest { + companion object { + private const val SINGLE = "{name} set up an allowance for you" + private const val SEVERAL = "{names} set up allowances for you" + private val AND_LIST: (List) -> String = { it.joinToString(" and ") } + } + + @Test + fun `offers accepted at once are announced in one description`() { + assertEquals( + "Alice and Bob set up allowances for you", + allowanceOffersAcceptedDescription(listOf("Alice", "Bob"), SINGLE, SEVERAL, AND_LIST), + ) + } + + @Test + fun `one offer keeps the single description`() { + assertEquals( + "Alice set up an allowance for you", + allowanceOffersAcceptedDescription(listOf("Alice"), SINGLE, SEVERAL, AND_LIST), + ) + } + + @Test + fun `no offer is not announced`() { + assertNull(allowanceOffersAcceptedDescription(emptyList(), SINGLE, SEVERAL, AND_LIST)) + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 60237ad8c2..ca8ea6fbf4 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -287,6 +287,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val paymentSubmissionActive = MutableStateFlow(false) private val paykitPaymentRequestRepo = mock() private val paykitAllowanceRepo = mock() + private val allowanceUpdating = MutableStateFlow(false) private val noCoverageRelease: () -> Unit = {} private val paykitPaymentProofRepo = mock() private val paykitPaymentRequestDiagnostics = mock() @@ -788,6 +789,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.isProcessing(any())).thenReturn(false) whenever(paykitPaymentProofRepo.onchainPaymentResolutions).thenReturn(onchainPaymentResolutions) whenever(paykitAllowanceRepo.events).thenReturn(MutableSharedFlow()) + whenever(paykitAllowanceRepo.isUpdatingFlow).thenReturn(allowanceUpdating) + whenever(paykitAllowanceRepo.offersToAnnounce).thenReturn(MutableStateFlow(emptyList())) whenever { paykitAllowanceRepo.refresh() }.thenReturn(Result.success(Unit)) whenever { paykitAllowanceRepo.beginManualPayment(any(), any()) }.thenReturn(Result.success(null)) whenever { paykitAllowanceRepo.acceptOffersFromAllowers(any(), any()) }.thenReturn(emptyList()) @@ -6659,6 +6662,29 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo, never()).beginPaymentRequest(request) } + @Test + fun `a request held while allowances update is presented once they settle`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + sut.setIsAuthenticated(true) + runCurrent() + val request = paymentRequest() + whenever(privatePaykitRepo.beginPaymentRequest(request)) + .thenReturn(Result.success(PublicPaykitPaymentResult.PrivateLinkPending)) + whenever(paykitAllowanceRepo.isUpdating).thenReturn(true) + whenever(paykitAllowanceRepo.mayCover(request)).thenReturn(true) + allowanceUpdating.value = true + pendingPaykitPaymentRequests.value = listOf(request) + runCurrent() + verify(privatePaykitRepo, never()).beginPaymentRequest(request) + + whenever(paykitAllowanceRepo.isUpdating).thenReturn(false) + allowanceUpdating.value = false + runCurrent() + + verify(privatePaykitRepo, times(1)).beginPaymentRequest(request) + } + @Test fun `the allowance pass never takes a request whose manual sheet is being prepared`() = test { enablePaykitUi() diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md index c545c9a758..0d1a8bd571 100644 --- a/journeys/allowances/README.md +++ b/journeys/allowances/README.md @@ -23,7 +23,10 @@ screen the sheet waits, so the notification is posted. The journeys set $5 a payment and $50 a month, the second stop on each slider, and the cap journey sets $5 a payment and $10 a month, the first monthly stop. Requests are one-time Payment Requests -created from the Payments tab of the payee, in the fiat unit. Dollar amounts on screen are converted +created from the Payments tab of the payee, priced in bitcoin. On Android, enter the amount in sats: +a request entered in the fiat unit is priced in USD, and allowances pay only bitcoin-priced requests, so it +stays manual. iOS converts a dollar entry to a bitcoin amount when it creates the request, so the fiat unit +works there. Dollar amounts on screen are converted at the current rate, so a sats amount next to them will differ between runs. ## Reference evidence diff --git a/journeys/allowances/end-stops-auto-pay.xml b/journeys/allowances/end-stops-auto-pay.xml index 65a0221380..609aee3833 100644 --- a/journeys/allowances/end-stops-auto-pay.xml +++ b/journeys/allowances/end-stops-auto-pay.xml @@ -3,7 +3,7 @@ Either side can end an allowance from its detail sheet, and from then on every request asks again. The first half ends it on the payer; the second half sets a fresh allowance and ends it on the payee. In both cases the payer's row keeps the amount that was paid automatically, and the - next request waits in the bell as an ordinary Payment Request. + next request opens as an ordinary Payment Request. Launch the E2E Bitkit app on both instances with an Active allowance that has paid at least one request automatically, per auto-pay-under-limit.xml @@ -13,14 +13,14 @@ Verify the sheet dismisses and the row's status line (testTag "AllowanceRowStatus") reads "Ended · " followed by the amount paid automatically, such as "Ended · $2.00 paid automatically", with the row dimmed Tap the row and verify the detail explanation (testTag "AllowanceDetailStatus") reads "This allowance has ended. Every request asks again." with no swipe control On the payee instance, send the payer a one-time Payment Request for $2 with the note "AfterEnd" - On the payer instance, verify it is not paid: no "Payment Executed" notification, and the request waits in the bell (testTag "PaymentRequestsBell") as an ordinary Payment Request + On the payer instance, verify it is not paid: no "Payment Executed" notification, and the request opens as an ordinary Payment Request sheet, also listed under the bell (testTag "PaymentRequestsBell") Dismiss that request On the payee instance, open Subscriptions and the Allowances tab, and verify the payer's row reads "Ended" - Set and accept a fresh $5 a payment, $50 a month allowance between the same two instances, per set-and-accept.xml + Set a fresh $5 a payment, $50 a month allowance between the same two instances, per set-and-accept.xml; the payee's wallet accepts it by itself On the payee instance, tap the Active row, verify the detail ends with "Swipe To End Allowance", and swipe it to the end Verify the payee's row reads "Ended" - On the payer instance, verify its row for that allowance reads "Ended"; tap it and verify the detail sheet shows PAID AUTOMATICALLY "$0.00" (testTag "AllowancePaidSoFar") + On the payer instance, verify its row for that allowance reads "Ended", with no amount since nothing was paid automatically On the payee instance, send the payer a one-time Payment Request for $2 with the note "AfterPayeeEnd" - On the payer instance, verify it is not paid and waits in the bell as an ordinary Payment Request, then dismiss it + On the payer instance, verify it is not paid and opens as an ordinary Payment Request sheet, then dismiss it diff --git a/journeys/allowances/monthly-cap-reached.xml b/journeys/allowances/monthly-cap-reached.xml index 1d97e83df0..b2a7d48af5 100644 --- a/journeys/allowances/monthly-cap-reached.xml +++ b/journeys/allowances/monthly-cap-reached.xml @@ -9,7 +9,7 @@ Launch the E2E Bitkit app on both instances with no Active allowance between them and the payer's notification permission granted - On the payer instance, set an allowance for the payee as in set-and-accept.xml, but with the $5 stop (testTag "AllowancePerPaymentStop-1") and the $10 stop (testTag "AllowanceMonthlyStop-0"), and have the payee accept it + On the payer instance, set an allowance for the payee as in set-and-accept.xml, but with the $5 stop (testTag "AllowancePerPaymentStop-1") and the $10 stop (testTag "AllowanceMonthlyStop-0"); the payee's wallet accepts it by itself Verify the payer's row reads "Active · $5 a payment" with "$ 10.00" over "Monthly limit" On the payee instance, send the payer a one-time Payment Request for $4 with the note "Cap1" On the payer instance, verify it is paid without a sheet and a "Payment Executed" notification reads "Auto-pay sent $4.00 to <payee>" diff --git a/journeys/allowances/pin-gates-allowance-changes.xml b/journeys/allowances/pin-gates-allowance-changes.xml index c4fd12ff67..7f773de11c 100644 --- a/journeys/allowances/pin-gates-allowance-changes.xml +++ b/journeys/allowances/pin-gates-allowance-changes.xml @@ -13,11 +13,12 @@ On the payer instance, open Subscriptions, tap the Allowances tab, tap Add (testTag "AllowanceAdd"), pick the payee and keep $5 a payment and $50 a month Tap Save Allowance (testTag "AllowanceSave") - Verify the PIN pad shows over the sheet, then press back + Verify the PIN pad shows over the sheet and reads "Please enter your PIN code to confirm this allowance change.", then press back Verify the Set sheet (testTag "SetAllowance") is still showing with the same payee and limits, and the payee instance gets no "Allowance Added" notification Tap Save Allowance again and enter the PIN Verify the sheet dismisses and the payer's row reads "Waiting for an answer" - On the payee instance, verify "Allowance Added" arrives with no review sheet (testTag "AllowanceReview"), no tap and no PIN, and the payer's row turns Active + On the payee instance, verify "Allowance Added" arrives with no review sheet (testTag "AllowanceReview"), no tap and no PIN + On the payer instance, verify the payee's row turns Active On the payee instance, send the payer a one-time Payment Request for $2 and verify the payer pays it automatically with no PIN pad, per auto-pay-under-limit.xml On the payer instance, tap the payee's row and swipe "Swipe To End Allowance" to the end Verify the PIN pad shows, press back, and verify the detail sheet (testTag "AllowanceDetail") is back with the swipe control at its start and the allowance still Active