From 9511b3262d7e4e8ba05d991a7d6c8302e30a2a4f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 16:48:43 +0200 Subject: [PATCH 01/12] chore: build paykit from the local allowances stack --- app/src/main/java/to/bitkit/services/PaykitSdkService.kt | 2 ++ settings.gradle.kts | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 15a47473a1..c11a8e37bd 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -807,6 +807,7 @@ class PaykitSdkService @Inject constructor( paymentEndpointIdentifier: String, proofJson: String, billingPeriod: PaykitBillingPeriod? = null, + allowanceId: String? = null, ): PaymentRequestRecord { isSetup.await() return operationMutex.withLock { @@ -818,6 +819,7 @@ class PaykitSdkService @Inject constructor( PaymentProofSubmission( billingPeriod = billingPeriod?.sdkValue, paymentEndpointIdentifier = paymentEndpointIdentifier, + allowanceId = allowanceId, proof = PrivateJsonObject(proofJson), ), ) diff --git a/settings.gradle.kts b/settings.gradle.kts index 9790eeb9eb..4f0e0826c0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -36,6 +36,11 @@ plugins { dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { + // Allowances demo: Paykit built locally from pubky/paykit-rs #161, only for this branch. + exclusiveContent { + forRepository { maven { url = uri(rootDir.resolve("../maven")) } } + filter { includeModule("com.synonym", "paykit-android") } + } mavenLocal() google() mavenCentral() From 90a8f17148eecd56dbf6f4a5c760bc9282f9e9c8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 16:48:43 +0200 Subject: [PATCH 02/12] chore: let an explicit e2e homegate url win on every backend --- app/build.gradle.kts | 2 ++ app/src/main/java/to/bitkit/env/Env.kt | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 955d5e477d..bfbce68237 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -80,6 +80,7 @@ val e2eLocalHostEnv = providers.environmentVariable("E2E_LOCAL_HOST").orElse("10 val e2eHomegateUrlEnv = providers.environmentVariable("E2E_HOMEGATE_URL") .orElse(e2eLocalHostEnv.map { "http://$it:6288" }) val e2eHomeserverPubkyEnv = providers.environmentVariable("E2E_HOMESERVER_PUBKY").orElse("") +val e2eHomegateOverrideEnv = providers.environmentVariable("E2E_HOMEGATE_URL").orElse("") val geoEnv = envFlag("GEO", default = true) val paykitUiDisabledEnv = envFlag("PAYKIT_UI_DISABLED", default = false) val trezorBridgeEnv = localProp("TREZOR_BRIDGE").map { it.toBoolean().toString() }.orElse("false") @@ -325,6 +326,7 @@ androidComponents { buildConfigFields.put("E2E_LOCAL_HOST", e2eLocalHostEnv.stringField()) buildConfigFields.put("E2E_HOMEGATE_URL", e2eHomegateUrlEnv.stringField()) buildConfigFields.put("E2E_HOMESERVER_PUBKY", e2eHomeserverPubkyEnv.stringField()) + buildConfigFields.put("E2E_HOMEGATE_OVERRIDE", e2eHomegateOverrideEnv.stringField()) buildConfigFields.put("TREZOR_BRIDGE", trezorBridgeEnv.booleanField()) buildConfigFields.put("TREZOR_BRIDGE_URL", trezorBridgeUrlEnv.stringField()) buildConfigFields.put("GEO", geoEnv.booleanField()) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index dd5d4ab8a7..83e8360cc0 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -169,6 +169,10 @@ internal object Env { val homegateUrl: String get() { + // An explicit E2E_HOMEGATE_URL wins on every backend, as on iOS: demos run their own homegate. + if (isE2eTest && BuildConfig.E2E_HOMEGATE_OVERRIDE.isNotBlank()) { + return BuildConfig.E2E_HOMEGATE_OVERRIDE + } if (isLocalE2eBackend) { return e2eHomegateUrl } From a846800b9911f0b541a8375b9a40056ed5cf6bdf Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 17:01:52 +0200 Subject: [PATCH 03/12] feat: add the allowance model, sdk calls and repo contract --- .../java/to/bitkit/data/keychain/Keychain.kt | 1 + .../to/bitkit/repositories/PaykitAllowance.kt | 267 ++++++++++++++++++ .../repositories/PaykitAllowanceRepo.kt | 77 +++++ .../to/bitkit/services/PaykitSdkService.kt | 198 +++++++++++++ app/src/main/java/to/bitkit/ui/ContentView.kt | 4 +- .../java/to/bitkit/ui/components/SheetHost.kt | 7 + 6 files changed, 553 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt create mode 100644 app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index fefa721567..1826a6bfa4 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -236,6 +236,7 @@ class Keychain @Inject constructor( PAYKIT_SDK_STATE, PAYKIT_PENDING_PAYMENT_PROOFS, PAYKIT_PRESENTED_PAYMENT_REQUESTS, + PAYKIT_ALLOWANCE_STATE, PUBKY_SECRET_KEY, } } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt new file mode 100644 index 0000000000..6fca0eeca3 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt @@ -0,0 +1,267 @@ +package to.bitkit.repositories + +import androidx.compose.runtime.Immutable +import com.synonym.paykit.AllowanceAccountingHistory +import com.synonym.paykit.AllowanceAmountRange +import com.synonym.paykit.AllowanceLifecycleState +import com.synonym.paykit.AllowanceLocalRole +import com.synonym.paykit.AllowancePeriod +import com.synonym.paykit.AllowancePeriodLimit +import com.synonym.paykit.AllowanceRecord +import com.synonym.paykit.AllowanceTerms +import com.synonym.paykit.PaymentExecutionMode +import com.synonym.paykit.PaymentExecutionStatus +import kotlinx.serialization.Serializable +import java.math.BigDecimal +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import kotlin.time.Instant +import kotlin.time.toJavaInstant +import kotlin.time.toKotlinInstant + +/** + * An Allowance between this wallet and one contact link, built from the SDK record. + * Eligibility always runs on real time. + */ +@Immutable +data class PaykitAllowance( + val id: Id, + val role: Role, + val lifecycleState: AllowanceLifecycleState, + val isProposedByMe: Boolean, + val perPaymentMaxSats: ULong?, + val monthlyLimitSats: ULong?, + val monthlyAnchor: Instant?, + val activeFrom: Instant? = null, + val expiresAt: Instant? = null, + val allowedPaymentEndpointIdentifiers: List? = null, + val lastEventAt: Instant? = null, +) { + @Serializable + data class Id( + val counterparty: String, + val counterpartyReceiverPath: String, + val allowanceId: String, + ) + + @Serializable + enum class Role { ALLOWER, ALLOWEE } + + enum class Status { + /** Sent by this wallet; the other side has not answered yet. */ + AWAITING_ANSWER, + + /** Received; this wallet must accept or decline. */ + AWAITING_MY_ANSWER, + ACTIVE, + NOT_YET_ACTIVE, + EXPIRED, + DECLINED, + ENDED, + CONFLICTED, + } + + val counterparty: String get() = id.counterparty + val counterpartyReceiverPath: String get() = id.counterpartyReceiverPath + val allowanceId: String get() = id.allowanceId + + /** The payer side: this wallet pays the counterparty's requests automatically. */ + val isAllower: Boolean get() = role == Role.ALLOWER + + val canEnd: Boolean + get() = when (lifecycleState) { + AllowanceLifecycleState.ACCEPTED -> true + AllowanceLifecycleState.PROPOSED -> isProposedByMe + else -> false + } + + val isAnswerable: Boolean get() = lifecycleState == AllowanceLifecycleState.PROPOSED && !isProposedByMe + + fun status(now: Instant): Status = when (lifecycleState) { + AllowanceLifecycleState.PROPOSED -> if (isProposedByMe) Status.AWAITING_ANSWER else Status.AWAITING_MY_ANSWER + AllowanceLifecycleState.ACCEPTED -> when { + expiresAt != null && now >= expiresAt -> Status.EXPIRED + activeFrom != null && now < activeFrom -> Status.NOT_YET_ACTIVE + else -> Status.ACTIVE + } + AllowanceLifecycleState.REJECTED -> Status.DECLINED + AllowanceLifecycleState.ENDED -> Status.ENDED + AllowanceLifecycleState.CONFLICTED, AllowanceLifecycleState.UNKNOWN -> Status.CONFLICTED + } + + companion object { + fun from(record: AllowanceRecord): PaykitAllowance? { + val role = when (record.localRole) { + AllowanceLocalRole.ALLOWER -> Role.ALLOWER + AllowanceLocalRole.ALLOWEE -> Role.ALLOWEE + else -> return null + } + val terms = record.terms ?: return null + if (terms.asset() != PaykitIssuerInterop.BITCOIN_ASSET) return null + val monthly = terms.periodLimits().firstOrNull { isMonthly(it.period()) } + return PaykitAllowance( + id = Id(record.counterparty, record.counterpartyReceiverPath, record.allowanceId), + role = role, + lifecycleState = record.state, + isProposedByMe = record.proposalOutboundMessageId != null, + perPaymentMaxSats = terms.perPaymentAmount()?.let { satsFromBitcoinAmount(it.maximum()) }, + monthlyLimitSats = monthly?.amountLimit()?.let(::satsFromBitcoinAmount), + monthlyAnchor = monthly?.period()?.anchor()?.let(PaykitAllowanceTime::parse), + activeFrom = terms.activeFrom()?.let(PaykitAllowanceTime::parse), + expiresAt = terms.expiresAt()?.let(PaykitAllowanceTime::parse), + allowedPaymentEndpointIdentifiers = terms.allowedPaymentEndpointIdentifiers(), + lastEventAt = record.lastEventAt?.let(PaykitAllowanceTime::parse), + ) + } + + fun isMonthly(period: AllowancePeriod): Boolean = + period.kind() == "anchored" && period.every() == 1uL && period.unit() == "month" + + /** BTC decimal string to sats; unlike [toPaykitSats] a zero amount is valid here. */ + fun satsFromBitcoinAmount(amount: String): ULong? { + if (amount.split('.').all { part -> part.all { it == '0' } }) return 0uL + return amount.toPaykitSats() + } + } +} + +/** One grant as the user sees it: the same limits proposed on each of a contact's supported links. */ +@Immutable +data class PaykitAllowanceEntry( + val id: String, + val allowances: List, + val limits: PaykitAllowanceLimits?, +) { + val primary: PaykitAllowance + get() = allowances.firstOrNull { it.lifecycleState == AllowanceLifecycleState.ACCEPTED } ?: allowances.first() + val counterparty: String get() = primary.counterparty + val role: PaykitAllowance.Role get() = primary.role + val perPaymentMaxSats: ULong? get() = primary.perPaymentMaxSats + val monthlyLimitSats: ULong? get() = primary.monthlyLimitSats + val canEnd: Boolean get() = allowances.any { it.canEnd } + val isAnswerable: Boolean get() = allowances.any { it.isAnswerable } + + fun status(now: Instant): PaykitAllowance.Status = primary.status(now) +} + +/** Limits picked in USD on the Set Allowance sheet, converted once to whole-sat BTC terms. */ +@Serializable +@Immutable +data class PaykitAllowanceLimits( + val perPaymentUsd: Int, + val monthlyUsd: Int, + val perPaymentSats: ULong, + val monthlySats: ULong, +) { + /** Terms Bitkit proposes: per-payment range 0...max, an anchored UTC calendar month, and the endpoints Bitkit pays. */ + fun terms(monthAnchor: Instant, allowedPaymentEndpointIdentifiers: List): AllowanceTerms { + val perPayment = AllowanceAmountRange(minimum = "0", maximum = perPaymentSats.toBitcoinDecimal()) + val month = AllowancePeriod( + kind = "anchored", + every = 1uL, + unit = "month", + anchor = PaykitAllowanceTime.format(monthAnchor), + ) + val monthly = AllowancePeriodLimit( + amountLimit = monthlySats.toBitcoinDecimal(), + paymentCountLimit = null, + period = month, + ) + return AllowanceTerms( + asset = PaykitIssuerInterop.BITCOIN_ASSET, + perPaymentAmount = perPayment, + periodLimits = listOf(monthly), + lifetimeAmountLimit = null, + activeFrom = null, + expiresAt = null, + allowedPaymentEndpointIdentifiers = allowedPaymentEndpointIdentifiers, + ) + } + + companion object { + /** Slider stops on the Set Allowance sheet, in whole US dollars. */ + val PER_PAYMENT_STOPS_USD = listOf(1, 5, 10, 20, 50) + + /** Slider stops on the Set Allowance sheet, in whole US dollars. */ + val MONTHLY_STOPS_USD = listOf(10, 50, 100, 200, 500) + } +} + +internal fun ULong.toBitcoinDecimal(): String = + BigDecimal(toString()).movePointLeft(8).stripTrailingZeros().toPlainString() + +object PaykitAllowanceTime { + private val utc = ZoneOffset.UTC + + fun format(instant: Instant): String = + DateTimeFormatter.ISO_INSTANT.format(instant.toJavaInstant().truncatedTo(ChronoUnit.MILLIS)) + + fun parse(value: String): Instant? = runCatching { Instant.parse(value) }.getOrNull() + + /** First instant of the UTC calendar month that contains [instant]. */ + fun monthStart(containing: Instant): Instant = ZonedDateTime.ofInstant(containing.toJavaInstant(), utc) + .withDayOfMonth(1) + .truncatedTo(ChronoUnit.DAYS) + .toInstant() + .toKotlinInstant() + + /** + * The anchored monthly window `[start, end)` that contains [instant]. Anchors on a day that a short month lacks + * clamp to that month's last day, as the spec's anchored-period arithmetic does. + */ + fun monthlyWindow(anchor: Instant, containing: Instant): Pair { + val anchorTime = ZonedDateTime.ofInstant(anchor.toJavaInstant(), utc) + val dateTime = ZonedDateTime.ofInstant(containing.toJavaInstant(), utc) + // Every boundary counts from the original anchor, so a clamped February never shortens later months. + val boundary = { index: Long -> anchorTime.plusMonths(index).toInstant().toKotlinInstant() } + var index = (dateTime.year - anchorTime.year) * 12L + dateTime.monthValue - anchorTime.monthValue + while (boundary(index) > containing) index-- + while (boundary(index + 1) <= containing) index++ + return boundary(index) to boundary(index + 1) + } +} + +/** + * Wallet-side capacity preflight. The SDK checks capacity only after automatic Acceptance, so Bitkit sums this + * Allowance's live automatic attempts in the current month first and keeps an over-cap request on the manual flow. + */ +object PaykitAllowanceCapacity { + data class Attempt( + val allowanceId: String, + val amountSats: ULong, + val admittedAt: Instant, + val isLive: Boolean, + ) + + fun usedSats(allowanceId: String, attempts: List, anchor: Instant, now: Instant): ULong { + val (start, end) = PaykitAllowanceTime.monthlyWindow(anchor, now) + return attempts + .filter { it.allowanceId == allowanceId && it.isLive && it.admittedAt >= start && it.admittedAt < end } + .fold(0uL) { total, attempt -> total + attempt.amountSats } + } + + fun fits(amountSats: ULong, allowance: PaykitAllowance, attempts: List, now: Instant): Boolean { + val perPaymentMax = allowance.perPaymentMaxSats + if (perPaymentMax != null && amountSats > perPaymentMax) return false + val monthlyLimit = allowance.monthlyLimitSats ?: return true + val anchor = allowance.monthlyAnchor ?: return true + return usedSats(allowance.allowanceId, attempts, anchor, now) + amountSats <= monthlyLimit + } + + fun attempts(history: AllowanceAccountingHistory): List = history.occurrences + .flatMap { it.attempts } + .mapNotNull { attempt -> + if (attempt.mode != PaymentExecutionMode.AUTOMATIC) return@mapNotNull null + val allowanceId = attempt.allowanceId ?: return@mapNotNull null + val admittedAt = PaykitAllowanceTime.parse(attempt.admittedAt) ?: return@mapNotNull null + val amountSats = PaykitAllowance.satsFromBitcoinAmount(attempt.amount.value()) ?: return@mapNotNull null + Attempt( + allowanceId = allowanceId, + amountSats = amountSats, + admittedAt = admittedAt, + isLive = attempt.status != PaymentExecutionStatus.FAILED, + ) + } +} diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt new file mode 100644 index 0000000000..0a38837efa --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -0,0 +1,77 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +// CONTRACT (allowances port): the public API below is fixed; bodies are filled by the core worker. + +sealed interface PaykitAllowanceEvent { + data class PaidAutomatically(val counterparty: String, val amountSats: ULong, val paymentId: String) : PaykitAllowanceEvent + data class LimitReached(val counterparty: String, val amountSats: ULong) : PaykitAllowanceEvent + data object LedgerChanged : PaykitAllowanceEvent +} + +enum class PaykitAllowanceAutoPayResult { NOT_COVERED, MANUAL, STARTED, COMPLETED } + +@Singleton +class PaykitAllowanceRepo @Inject constructor() { + private val _entries = MutableStateFlow>(emptyList()) + + /** Grants grouped across a contact's links, newest first. */ + val entries: StateFlow> = _entries.asStateFlow() + + private val _autoPaidSats = MutableStateFlow>(emptyMap()) + + /** Sats paid automatically per entry id, for "Paid so far". */ + val autoPaidSats: StateFlow> = _autoPaidSats.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 16) + val events: SharedFlow = _events.asSharedFlow() + + fun entry(id: String): PaykitAllowanceEntry? = _entries.value.firstOrNull { it.id == id } + + suspend fun activate(identity: String?): Unit = TODO() + + fun deactivate(): Unit = TODO() + + suspend fun refresh(): Result = TODO() + + suspend fun propose(contactPublicKey: String, limits: PaykitAllowanceLimits): Result = TODO() + + suspend fun accept(entryId: String): Result = TODO() + + suspend fun reject(entryId: String): Result = TODO() + + suspend fun end(entryId: String): Result = TODO() + + fun proposalForPresentation(): PaykitAllowanceEntry? = TODO() + + suspend fun markProposalPresented(entryId: String): Unit = TODO() + + /** Pays covered incoming requests headlessly; returns true when any request was handled. */ + suspend fun processIncomingRequests(requests: List): Boolean = TODO() + + /** True while a request is covered by an active allowance or is being paid automatically: keep it off the Send sheet. */ + suspend fun isAutomaticallyHandling(request: PaykitPaymentRequest): Boolean = TODO() + + /** Request ids paid automatically, for the "Auto-paid" tag in payment history. */ + fun isAutoPaid(id: PaykitPaymentRequestId): Boolean = TODO() + + /** Reports a manual payment of a request to the allowance ledger; returns the attempt id or null when no ledger applies. */ + suspend fun beginManualPayment(request: PaykitPaymentRequest, paymentEndpointIdentifier: String): Result = TODO() + + suspend fun manualLightningPaymentSent(attemptId: String, paymentHash: String): Unit = TODO() + + /** [succeeded] null = outcome unknown (pending). */ + suspend fun finishManualPayment(attemptId: String, succeeded: Boolean?, transactionId: String? = null): Unit = TODO() + + suspend fun lightningPaymentSettled(paymentHash: String, succeeded: Boolean): Unit = TODO() + + suspend fun recover(): Unit = TODO() +} diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index c11a8e37bd..72a6282fcb 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -2,6 +2,15 @@ package to.bitkit.services import android.content.Context import com.synonym.bitkitcore.mnemonicToSeed +import com.synonym.paykit.AllowanceAccountingReconciliation +import com.synonym.paykit.AllowanceAccountingState +import com.synonym.paykit.AllowanceAssociationRecord +import com.synonym.paykit.AllowanceCandidate +import com.synonym.paykit.AllowanceFilter +import com.synonym.paykit.AllowanceLocalRole +import com.synonym.paykit.AllowanceRecord +import com.synonym.paykit.AllowanceSelectionInput +import com.synonym.paykit.AllowanceTerms import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.ContactRecord import com.synonym.paykit.ContactUpdate @@ -11,6 +20,7 @@ import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.OutboundPrivateCounterpartySendReport +import com.synonym.paykit.OutboundPrivateSendReport import com.synonym.paykit.PaykitAndroid import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitProfile @@ -20,6 +30,12 @@ import com.synonym.paykit.PaykitReceiverMarker import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PaykitSdkDefaults import com.synonym.paykit.PaymentAmountContext +import com.synonym.paykit.PaymentAttemptDecision +import com.synonym.paykit.PaymentAttemptRecord +import com.synonym.paykit.PaymentExecutionChecks +import com.synonym.paykit.PaymentOccurrence +import com.synonym.paykit.PaymentOccurrenceRecord +import com.synonym.paykit.PaymentOutcomeReport import com.synonym.paykit.PaymentPayload import com.synonym.paykit.PaymentProofSubmission import com.synonym.paykit.PaymentReference @@ -27,6 +43,7 @@ import com.synonym.paykit.PaymentRequestAmount import com.synonym.paykit.PaymentRequestFilter import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestRecurrence +import com.synonym.paykit.PaymentRequestScope import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PaymentTarget import com.synonym.paykit.PrivateContactPaymentResolution @@ -42,6 +59,7 @@ import com.synonym.paykit.PrivateReceivingDetail import com.synonym.paykit.PrivateReceivingDetailReservationResponse import com.synonym.paykit.PrivateReceivingDetailReservationResponseKind import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport +import com.synonym.paykit.PrivateStreamIntakeReport import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkyAuthRequest import com.synonym.paykit.PubkyClientConfig @@ -827,6 +845,186 @@ class PaykitSdkService @Inject constructor( } } + suspend fun listAllowances(filter: AllowanceFilter): List { + isSetup.await() + return operationMutex.withLock { + handle().listAllowances(filter) + } + } + + suspend fun proposeAllowance( + counterparty: String, + counterpartyReceiverPath: String, + localRole: AllowanceLocalRole, + terms: AllowanceTerms, + ): AllowanceRecord { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.proposeAllowance(counterparty, counterpartyReceiverPath, localRole, terms) + } + } + } + + suspend fun acceptAllowance( + counterparty: String, + counterpartyReceiverPath: String, + allowanceId: String, + ): AllowanceRecord { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.acceptAllowance(counterparty, counterpartyReceiverPath, allowanceId) + } + } + } + + suspend fun rejectAllowance( + counterparty: String, + counterpartyReceiverPath: String, + allowanceId: String, + ): AllowanceRecord { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.rejectAllowance(counterparty, counterpartyReceiverPath, allowanceId) + } + } + } + + suspend fun endAllowance( + counterparty: String, + counterpartyReceiverPath: String, + allowanceId: String, + ): AllowanceRecord { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.endAllowance(counterparty, counterpartyReceiverPath, allowanceId) + } + } + } + + suspend fun receivePrivateMessages( + counterparty: String, + counterpartyReceiverPath: String, + ): PrivateStreamIntakeReport { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.receivePrivateMessages(counterparty, counterpartyReceiverPath) + } + } + } + + suspend fun processOutboundPrivateMessages( + counterparty: String, + counterpartyReceiverPath: String, + ): OutboundPrivateSendReport { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.processOutboundPrivateMessages(counterparty, counterpartyReceiverPath) + } + } + } + + suspend fun allowanceAccountingState(): AllowanceAccountingState? { + isSetup.await() + return operationMutex.withLock { + handle().allowanceAccountingState() + } + } + + suspend fun reconcileAllowanceAccounting( + reconciliation: AllowanceAccountingReconciliation, + ): AllowanceAccountingState { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.reconcileAllowanceAccounting(reconciliation) + } + } + } + + suspend fun evaluateAllowanceCandidates( + scope: PaymentRequestScope, + trustedTime: String, + ): List { + isSetup.await() + return operationMutex.withLock { + handle().evaluateAllowanceCandidates(scope, trustedTime) + } + } + + suspend fun acceptPaymentRequestAutomatically( + scope: PaymentRequestScope, + selection: AllowanceSelectionInput, + checks: PaymentExecutionChecks, + ): AllowanceAssociationRecord { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.acceptPaymentRequestAutomatically(scope, selection, checks) + } + } + } + + suspend fun reserveAutomaticPayment( + occurrence: PaymentOccurrence, + expectedAssociationRevision: ULong, + checks: PaymentExecutionChecks, + ): PaymentAttemptDecision { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.reserveAutomaticPayment(occurrence, expectedAssociationRevision, checks) + } + } + } + + suspend fun reserveManualPayment( + occurrence: PaymentOccurrence, + checks: PaymentExecutionChecks, + ): PaymentAttemptDecision { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.reserveManualPayment(occurrence, checks) + } + } + } + + suspend fun beginPaymentExecution( + attemptId: String, + checks: PaymentExecutionChecks, + ): PaymentAttemptDecision { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.beginPaymentExecution(attemptId, checks) + } + } + } + + suspend fun recordPaymentOutcome(report: PaymentOutcomeReport): PaymentAttemptRecord { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.recordPaymentOutcome(report) + } + } + } + + suspend fun markPaymentManualOnly(occurrence: PaymentOccurrence): PaymentOccurrenceRecord { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.markPaymentManualOnly(occurrence) + } + } + } + suspend fun rejectPaymentRequest( counterparty: String, counterpartyReceiverPath: String, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 57488f528c..ac4ca0bea9 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -517,7 +517,7 @@ fun ContentView( is Sheet.Widgets -> Colors.Gray7 // Meet the top of the subscription sheets' own gradient, so the grabber strip // does not sit a shade darker than the content right below it. - is Sheet.Subscription -> Colors.Gray6 + is Sheet.Subscription, is Sheet.Allowance -> Colors.Gray6 else -> DefaultSheetContainerColor }, sheets = { @@ -569,6 +569,8 @@ fun ContentView( is Sheet.Subscription -> SubscriptionSheet(appViewModel, sheet.route) + is Sheet.Allowance -> Unit // CONTRACT: UI worker renders AllowanceSheet(sheet.route) here + is Sheet.ActivityDateRangeSelector -> DateRangeSelectorSheet() is Sheet.ActivityTagSelector -> TagSelectorSheet() is Sheet.Pin -> PinSheet(sheet, appViewModel) diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index 7f284dd301..5241ac01b7 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -54,6 +54,12 @@ enum class SheetHandlePlacement { ContentOverlay, } +sealed interface AllowanceRoute { + data object Set : AllowanceRoute + data class Review(val entryId: String) : AllowanceRoute + data class Details(val entryId: String) : AllowanceRoute +} + sealed interface SubscriptionRoute { data class Review(val id: PaykitSubscriptionId) : SubscriptionRoute data class Success(val id: PaykitSubscriptionId) : SubscriptionRoute @@ -75,6 +81,7 @@ sealed interface Sheet { data object PaymentRequests : Sheet data object CreateSubscription : Sheet data class Subscription(val route: SubscriptionRoute) : Sheet + data class Allowance(val route: AllowanceRoute) : Sheet data class Pin(val route: PinRoute = PinRoute.Prompt()) : Sheet data object ChangePin : Sheet data object DisablePin : Sheet From 323bfd0df8a467de59fb1e0134e59d6336c7e8f0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 17:11:51 +0200 Subject: [PATCH 04/12] feat: keep rc55 paykit state readable across the allowance sdk --- .../to/bitkit/services/PaykitSdkService.kt | 151 +++++++++-- .../services/PaykitSdkStateLayoutTest.kt | 252 ++++++++++++++++++ 2 files changed, 383 insertions(+), 20 deletions(-) create mode 100644 app/src/test/java/to/bitkit/services/PaykitSdkStateLayoutTest.kt diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 72a6282fcb..5e3515de83 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -247,6 +247,7 @@ class PaykitSdkService @Inject constructor( PaykitAndroid.initializeOrThrow(context) launch { republishIdentityIfNeeded() } operationMutex.withLock { + stateStore.resolveLegacyLayoutIfNeeded(::paykitProbeSdk) var handle = handle() try { handle.initialize() @@ -1381,40 +1382,150 @@ internal fun validatedApprovalClientId(requestClientId: String, approvedClientId return requestClientId } -private class PaykitSdkStateBlobStore( +/** + * Paykit #161 added `allowance_accounting` as the first field of the SDK state without bumping the state blob version, + * so the two layouts differ by one Option tag right after the version byte. Bitkit keeps the rc55 layout on disk while + * accounting is empty, so an rc55 build can still read the wallet, and records the stored layout in the revision. + */ +internal enum class PaykitSdkStateLayout(private val suffix: String) { + RC55("rc55"), + ALLOWANCES("alw"), + ; + + companion object { + /** Postcard encoding of state blob version 1, the first byte of every SDK state blob. */ + private const val VERSION_BYTE: Byte = 0x01 + + /** Postcard tag of an empty `allowance_accounting` Option in the #161 layout. */ + private const val NONE_TAG: Byte = 0x00 + + fun stored(sdkBytes: ByteArray): Pair { + val isEmptyAccounting = sdkBytes.size >= 2 && sdkBytes[0] == VERSION_BYTE && sdkBytes[1] == NONE_TAG + if (!isEmptyAccounting) return ALLOWANCES to sdkBytes + return RC55 to byteArrayOf(VERSION_BYTE) + sdkBytes.copyOfRange(2, sdkBytes.size) + } + + fun fromRevision(revision: String): PaykitSdkStateLayout? = + entries.firstOrNull { revision.endsWith(".${it.suffix}") } + } + + fun sdkBytes(storedBytes: ByteArray): ByteArray { + if (this != RC55 || storedBytes.firstOrNull() != VERSION_BYTE) return storedBytes + return byteArrayOf(VERSION_BYTE, NONE_TAG) + storedBytes.copyOfRange(1, storedBytes.size) + } + + fun revision(base: String) = "$base.$suffix" +} + +internal class PaykitSdkStateBlobStore( private val keychain: Keychain, + private val decodeSnapshot: (ByteArray) -> SdkStateBlobSnapshot = ::decodeSdkStateBlobSnapshot, + private val encodeSnapshot: (SdkStateBlobSnapshot) -> ByteArray = ::encodeSdkStateBlobSnapshot, + private val newBlob: (ByteArray) -> SdkStateBlob = ::SdkStateBlob, ) : SdkStateBlobStore { + companion object { + private const val TAG = "PaykitSdkStateBlobStore" + } + private val lock = Any() override fun loadStateBlob(): SdkStateBlobSnapshot? = synchronized(lock) { - val data = keychain.accessBlocking { - load(Keychain.Key.PAYKIT_SDK_STATE.name) - } ?: return@synchronized null - decodeSdkStateBlobSnapshot(data) + val snapshot = loadSnapshot() ?: return@synchronized null + val layout = PaykitSdkStateLayout.fromRevision(snapshot.revision) ?: return@synchronized snapshot + snapshot.copy(blob = newBlob(layout.sdkBytes(snapshot.blob.exportBytes()))) } override fun saveStateBlobAtomically( blob: SdkStateBlob, expectedRevision: String?, ): String = synchronized(lock) { - val currentRevision = keychain.accessBlocking { - load(Keychain.Key.PAYKIT_SDK_STATE.name) - } - ?.let { decodeSdkStateBlobSnapshot(it).revision } - if (currentRevision != expectedRevision) { - throw PaykitException.Storage( - code = "revision_conflict", - context = "SDK state revision changed", - ) - } + if (loadSnapshot()?.revision != expectedRevision) throw revisionConflict() - val nextRevision = UUID.randomUUID().toString() - val snapshot = SdkStateBlobSnapshot(blob = blob, revision = nextRevision) - keychain.accessBlocking { - upsert(Keychain.Key.PAYKIT_SDK_STATE.name, encodeSdkStateBlobSnapshot(snapshot)) - } + val (layout, bytes) = PaykitSdkStateLayout.stored(blob.exportBytes()) + val nextRevision = layout.revision(UUID.randomUUID().toString()) + saveSnapshot(SdkStateBlobSnapshot(blob = newBlob(bytes), revision = nextRevision)) nextRevision } + + /** + * Records the layout of a state blob saved before Bitkit marked layouts, which may come from an rc55 build. The SDK + * itself tells which layout decodes: a throwaway instance reads each candidate from memory. + */ + suspend fun resolveLegacyLayoutIfNeeded(probeSdk: (ByteArray) -> PaykitSdk) { + val legacy = unmarkedSnapshot() ?: return + val bytes = legacy.blob.exportBytes() + val decoded = listOf(PaykitSdkStateLayout.ALLOWANCES, PaykitSdkStateLayout.RC55).mapNotNull { layout -> + runSuspendCatching { + probeSdk(layout.sdkBytes(bytes)).use { + it.allowanceAccountingState() + layout to (runSuspendCatching { it.identityStatus()?.publicKey }.getOrNull() != null) + } + }.getOrNull() + } + // Postcard ignores trailing bytes, so a wrong layout can occasionally parse; the one holding the identity wins. + val chosen = decoded.firstOrNull { it.second }?.first ?: decoded.firstOrNull()?.first + if (chosen == null) { + Logger.error("Found no known layout for the stored Paykit state", context = TAG) + return + } + runCatching { markLayout(chosen, legacy.revision) } + .onSuccess { + Logger.info( + "Resolved the stored Paykit state layout as '$chosen' ('${decoded.size}' candidates decoded)", + context = TAG, + ) + } + .onFailure { Logger.warn("Failed to record the Paykit state layout", it, context = TAG) } + } + + private fun unmarkedSnapshot(): SdkStateBlobSnapshot? = synchronized(lock) { + runCatching { loadSnapshot() } + .onFailure { Logger.warn("Failed to read the stored Paykit state layout", it, context = TAG) } + .getOrNull() + ?.takeIf { PaykitSdkStateLayout.fromRevision(it.revision) == null } + } + + private fun markLayout(layout: PaykitSdkStateLayout, expectedRevision: String) = synchronized(lock) { + val snapshot = loadSnapshot() ?: return@synchronized + if (snapshot.revision != expectedRevision) throw revisionConflict() + saveSnapshot(snapshot.copy(revision = layout.revision(snapshot.revision))) + } + + private fun loadSnapshot(): SdkStateBlobSnapshot? = + keychain.accessBlocking { load(Keychain.Key.PAYKIT_SDK_STATE.name) }?.let(decodeSnapshot) + + private fun saveSnapshot(snapshot: SdkStateBlobSnapshot) { + val data = encodeSnapshot(snapshot) + keychain.accessBlocking { upsert(Keychain.Key.PAYKIT_SDK_STATE.name, data) } + } + + private fun revisionConflict() = PaykitException.Storage( + code = "revision_conflict", + context = "SDK state revision changed", + ) +} + +private fun paykitProbeSdk(sdkBytes: ByteArray) = PaykitSdk( + stateStore = PaykitSdkProbeStateStore(sdkBytes), + sessionProvider = PaykitSdkProbeSessionProvider, + config = paykitSdkConfig(), +) + +private class PaykitSdkProbeStateStore( + private val sdkBytes: ByteArray, +) : SdkStateBlobStore { + override fun loadStateBlob() = SdkStateBlobSnapshot(blob = SdkStateBlob(sdkBytes), revision = "probe") + + override fun saveStateBlobAtomically(blob: SdkStateBlob, expectedRevision: String?): String = + throw PaykitException.Storage(code = "read_only", context = "Paykit state layout probe is read-only") +} + +private object PaykitSdkProbeSessionProvider : SdkPubkySessionProvider { + override fun loadSessionAccess(): PubkySessionAccess? = null + + override fun publicStorageAvailable() = false + + override fun clearSessionAccess() = Unit } internal class PaykitSdkSessionProvider( diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkStateLayoutTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkStateLayoutTest.kt new file mode 100644 index 0000000000..5a05a180e7 --- /dev/null +++ b/app/src/test/java/to/bitkit/services/PaykitSdkStateLayoutTest.kt @@ -0,0 +1,252 @@ +package to.bitkit.services + +import com.synonym.paykit.IdentityStatus +import com.synonym.paykit.PaykitException +import com.synonym.paykit.PaykitSdk +import com.synonym.paykit.SdkStateBlob +import com.synonym.paykit.SdkStateBlobSnapshot +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.keychain.Keychain +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PaykitSdkStateLayoutTest : BaseUnitTest() { + private companion object { + val STATE_KEY = Keychain.Key.PAYKIT_SDK_STATE.name + } + + private val rc55Bytes = byteArrayOf(1, 7, 8) + private val emptyAccountingBytes = byteArrayOf(1, 0, 7, 8) + private val accountingBytes = byteArrayOf(1, 1, 9) + + private val keychain = mock() + private val blocking = mock() + private var storedData: ByteArray? = null + + @Before + fun setUp() { + whenever(keychain.accessBlocking(any())).doAnswer { + it.getArgument Any?>(0).invoke(blocking) + } + whenever(blocking.load(STATE_KEY)).doAnswer { storedData } + doAnswer { storedData = it.getArgument(1) }.whenever(blocking).upsert(eq(STATE_KEY), any()) + } + + @Test + fun `rc55 layout gains the empty accounting tag and loses it again`() { + val sdkBytes = PaykitSdkStateLayout.RC55.sdkBytes(rc55Bytes) + val (layout, storedBytes) = PaykitSdkStateLayout.stored(sdkBytes) + + assertContentEquals(emptyAccountingBytes, sdkBytes) + assertEquals(PaykitSdkStateLayout.RC55, layout) + assertContentEquals(rc55Bytes, storedBytes) + } + + @Test + fun `state with accounting keeps the allowances layout`() { + val (layout, storedBytes) = PaykitSdkStateLayout.stored(accountingBytes) + + assertEquals(PaykitSdkStateLayout.ALLOWANCES, layout) + assertContentEquals(accountingBytes, storedBytes) + assertContentEquals(accountingBytes, PaykitSdkStateLayout.ALLOWANCES.sdkBytes(accountingBytes)) + } + + @Test + fun `revision suffix names the stored layout`() { + assertEquals("id.rc55", PaykitSdkStateLayout.RC55.revision("id")) + assertEquals("id.alw", PaykitSdkStateLayout.ALLOWANCES.revision("id")) + assertEquals(PaykitSdkStateLayout.RC55, PaykitSdkStateLayout.fromRevision("id.rc55")) + assertEquals(PaykitSdkStateLayout.ALLOWANCES, PaykitSdkStateLayout.fromRevision("id.alw")) + assertNull(PaykitSdkStateLayout.fromRevision("3f1c0e9a-7b8d-4c2e-9f10-2a6b5c4d3e21")) + } + + @Test + fun `rc55 blob loads in the allowances layout`() { + storeSnapshot(rc55Bytes, "r1.rc55") + + val snapshot = assertNotNull(store().loadStateBlob()) + + assertContentEquals(emptyAccountingBytes, snapshot.blob.exportBytes()) + assertEquals("r1.rc55", snapshot.revision) + } + + @Test + fun `unmarked blob loads unchanged`() { + storeSnapshot(rc55Bytes, "legacy") + + val snapshot = assertNotNull(store().loadStateBlob()) + + assertContentEquals(rc55Bytes, snapshot.blob.exportBytes()) + assertEquals("legacy", snapshot.revision) + } + + @Test + fun `save without accounting stores the rc55 layout`() { + val store = store() + + val revision = store.saveStateBlobAtomically(blob(emptyAccountingBytes), expectedRevision = null) + + val stored = storedSnapshot() + assertTrue(revision.endsWith(".rc55")) + assertEquals(revision, stored.revision) + assertContentEquals(rc55Bytes, stored.blob.exportBytes()) + assertContentEquals(emptyAccountingBytes, store.loadStateBlob()?.blob?.exportBytes()) + } + + @Test + fun `save with accounting stores the allowances layout`() { + storeSnapshot(rc55Bytes, "r1.rc55") + val store = store() + + val revision = store.saveStateBlobAtomically(blob(accountingBytes), expectedRevision = "r1.rc55") + + val stored = storedSnapshot() + assertTrue(revision.endsWith(".alw")) + assertEquals(revision, stored.revision) + assertContentEquals(accountingBytes, stored.blob.exportBytes()) + assertContentEquals(accountingBytes, store.loadStateBlob()?.blob?.exportBytes()) + } + + @Test + fun `save rejects a stale revision`() { + storeSnapshot(rc55Bytes, "r1.rc55") + val store = store() + val loadedRevision = assertNotNull(store.loadStateBlob()).revision + val nextRevision = store.saveStateBlobAtomically(blob(accountingBytes), loadedRevision) + + for (staleRevision in listOf(loadedRevision, null)) { + val error = assertFailsWith { + store.saveStateBlobAtomically(blob(emptyAccountingBytes), staleRevision) + } + assertEquals("revision_conflict", error.code) + } + assertEquals(nextRevision, storedSnapshot().revision) + assertContentEquals(accountingBytes, storedSnapshot().blob.exportBytes()) + } + + @Test + fun `unmarked rc55 blob resolves to the rc55 layout`() = test { + storeSnapshot(rc55Bytes, "legacy") + val store = store() + val probes = mutableListOf() + + store.resolveLegacyLayoutIfNeeded { + probe(decodes = it.contentEquals(emptyAccountingBytes), hasIdentity = true).also(probes::add) + } + + val stored = storedSnapshot() + assertEquals("legacy.rc55", stored.revision) + assertContentEquals(rc55Bytes, stored.blob.exportBytes()) + assertContentEquals(emptyAccountingBytes, store.loadStateBlob()?.blob?.exportBytes()) + assertEquals(2, probes.size) + probes.forEach { verify(it).close() } + } + + @Test + fun `unmarked allowances blob resolves to the allowances layout`() = test { + storeSnapshot(accountingBytes, "legacy") + val store = store() + + store.resolveLegacyLayoutIfNeeded { probe(decodes = it.contentEquals(accountingBytes), hasIdentity = true) } + + val stored = storedSnapshot() + assertEquals("legacy.alw", stored.revision) + assertContentEquals(accountingBytes, stored.blob.exportBytes()) + assertContentEquals(accountingBytes, store.loadStateBlob()?.blob?.exportBytes()) + } + + @Test + fun `identity decides when both layouts decode`() = test { + val cases = listOf( + true to "legacy.rc55", + false to "legacy.alw", + ) + for ((rc55HasIdentity, expectedRevision) in cases) { + storeSnapshot(rc55Bytes, "legacy") + + store().resolveLegacyLayoutIfNeeded { + val isRc55Candidate = it.contentEquals(emptyAccountingBytes) + probe(decodes = true, hasIdentity = isRc55Candidate == rc55HasIdentity) + } + + assertEquals(expectedRevision, storedSnapshot().revision) + } + } + + @Test + fun `undecodable or marked blobs keep their revision`() = test { + storeSnapshot(rc55Bytes, "legacy") + store().resolveLegacyLayoutIfNeeded { probe(decodes = false, hasIdentity = false) } + assertEquals("legacy", storedSnapshot().revision) + + storeSnapshot(rc55Bytes, "r1.alw") + var probeCount = 0 + store().resolveLegacyLayoutIfNeeded { + probeCount++ + probe(decodes = true, hasIdentity = true) + } + assertEquals("r1.alw", storedSnapshot().revision) + assertEquals(0, probeCount) + } + + @Test + fun `resolution keeps a state saved while probing`() = test { + storeSnapshot(rc55Bytes, "legacy") + + store().resolveLegacyLayoutIfNeeded { + storeSnapshot(accountingBytes, "concurrent.alw") + probe(decodes = true, hasIdentity = true) + } + + val stored = storedSnapshot() + assertEquals("concurrent.alw", stored.revision) + assertContentEquals(accountingBytes, stored.blob.exportBytes()) + } + + private fun store() = PaykitSdkStateBlobStore(keychain, ::decode, ::encode, ::blob) + + private fun probe(decodes: Boolean, hasIdentity: Boolean): PaykitSdk { + val sdk = mock() + if (decodes) { + whenever { sdk.allowanceAccountingState() }.thenReturn(null) + } else { + whenever { sdk.allowanceAccountingState() } + .thenThrow(PaykitException.Storage("decode", "decode SDK state blob")) + } + val publicKey = if (hasIdentity) "pubky_test" else null + whenever { sdk.identityStatus() }.thenReturn(IdentityStatus(publicKey, liveSessionAvailable = false)) + return sdk + } + + private fun storeSnapshot(bytes: ByteArray, revision: String) { + storedData = encode(SdkStateBlobSnapshot(blob(bytes), revision)) + } + + private fun storedSnapshot() = decode(checkNotNull(storedData)) + + private fun blob(bytes: ByteArray): SdkStateBlob = mock { on { exportBytes() } doReturn bytes } + + private fun encode(snapshot: SdkStateBlobSnapshot) = + "${snapshot.revision}\n".encodeToByteArray() + snapshot.blob.exportBytes() + + private fun decode(data: ByteArray): SdkStateBlobSnapshot { + val separator = data.indexOf('\n'.code.toByte()) + return SdkStateBlobSnapshot( + blob = blob(data.copyOfRange(separator + 1, data.size)), + revision = data.copyOf(separator).decodeToString(), + ) + } +} From 26fd423a3996ff63ae236f3b589483fa738cfbae Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 17:49:32 +0200 Subject: [PATCH 05/12] feat: pay covered paykit requests through allowances --- .../to/bitkit/repositories/PaykitAllowance.kt | 17 +- .../repositories/PaykitAllowanceExecutor.kt | 761 +++++++++++++++++ .../repositories/PaykitAllowanceRepo.kt | 492 ++++++++++- .../repositories/PaykitAllowanceStore.kt | 92 ++ .../repositories/PaykitPaymentProofRepo.kt | 13 +- .../bitkit/repositories/PrivatePaykitRepo.kt | 72 ++ .../bitkit/repositories/PublicPaykitRepo.kt | 8 + .../PaykitAllowanceExecutorTest.kt | 798 ++++++++++++++++++ .../repositories/PaykitAllowanceRepoTest.kt | 500 +++++++++++ .../repositories/PaykitAllowanceTest.kt | 690 +++++++++++++++ .../PaykitPaymentProofRepoTest.kt | 105 ++- .../PrivatePaykitAllowancePaymentTest.kt | 246 ++++++ 12 files changed, 3735 insertions(+), 59 deletions(-) create mode 100644 app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt create mode 100644 app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt create mode 100644 app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt index 6fca0eeca3..82ddae7538 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt @@ -12,6 +12,8 @@ import com.synonym.paykit.AllowanceTerms import com.synonym.paykit.PaymentExecutionMode import com.synonym.paykit.PaymentExecutionStatus import kotlinx.serialization.Serializable +import to.bitkit.models.safe +import to.bitkit.services.PaykitReceiverPaths import java.math.BigDecimal import java.time.ZoneOffset import java.time.ZonedDateTime @@ -134,8 +136,13 @@ data class PaykitAllowanceEntry( val allowances: List, val limits: PaykitAllowanceLimits?, ) { + /** An accepted link before any other, and the contact's wallet link before their server link. */ val primary: PaykitAllowance - get() = allowances.firstOrNull { it.lifecycleState == AllowanceLifecycleState.ACCEPTED } ?: allowances.first() + get() = allowances.minBy { + val acceptedRank = if (it.lifecycleState == AllowanceLifecycleState.ACCEPTED) 0 else 2 + val walletRank = if (it.counterpartyReceiverPath == PaykitReceiverPaths.WALLET) 0 else 1 + acceptedRank + walletRank + } val counterparty: String get() = primary.counterparty val role: PaykitAllowance.Role get() = primary.role val perPaymentMaxSats: ULong? get() = primary.perPaymentMaxSats @@ -155,7 +162,9 @@ data class PaykitAllowanceLimits( val perPaymentSats: ULong, val monthlySats: ULong, ) { - /** Terms Bitkit proposes: per-payment range 0...max, an anchored UTC calendar month, and the endpoints Bitkit pays. */ + /** + * Terms Bitkit proposes: per-payment range 0...max, an anchored UTC calendar month, and the endpoints Bitkit pays. + */ fun terms(monthAnchor: Instant, allowedPaymentEndpointIdentifiers: List): AllowanceTerms { val perPayment = AllowanceAmountRange(minimum = "0", maximum = perPaymentSats.toBitcoinDecimal()) val month = AllowancePeriod( @@ -239,7 +248,7 @@ object PaykitAllowanceCapacity { val (start, end) = PaykitAllowanceTime.monthlyWindow(anchor, now) return attempts .filter { it.allowanceId == allowanceId && it.isLive && it.admittedAt >= start && it.admittedAt < end } - .fold(0uL) { total, attempt -> total + attempt.amountSats } + .fold(0uL) { total, attempt -> total.safe() + attempt.amountSats.safe() } } fun fits(amountSats: ULong, allowance: PaykitAllowance, attempts: List, now: Instant): Boolean { @@ -247,7 +256,7 @@ object PaykitAllowanceCapacity { if (perPaymentMax != null && amountSats > perPaymentMax) return false val monthlyLimit = allowance.monthlyLimitSats ?: return true val anchor = allowance.monthlyAnchor ?: return true - return usedSats(allowance.allowanceId, attempts, anchor, now) + amountSats <= monthlyLimit + return usedSats(allowance.allowanceId, attempts, anchor, now).safe() + amountSats.safe() <= monthlyLimit } fun attempts(history: AllowanceAccountingHistory): List = history.occurrences diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt new file mode 100644 index 0000000000..450df5cd34 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -0,0 +1,761 @@ +package to.bitkit.repositories + +import com.synonym.paykit.AccountingAmount +import com.synonym.paykit.AllowanceAccountingBlock +import com.synonym.paykit.AllowanceAccountingHistory +import com.synonym.paykit.AllowanceAccountingReconciliation +import com.synonym.paykit.AllowanceAccountingState +import com.synonym.paykit.AllowanceLifecycleState +import com.synonym.paykit.AllowanceSelectionInput +import com.synonym.paykit.PaymentAttemptDecision +import com.synonym.paykit.PaymentAttemptRecord +import com.synonym.paykit.PaymentExecutionChecks +import com.synonym.paykit.PaymentExecutionStatus +import com.synonym.paykit.PaymentOccurrence +import com.synonym.paykit.PaymentOutcome +import com.synonym.paykit.PaymentOutcomeReport +import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.lightningdevkit.ldknode.NodeException +import org.lightningdevkit.ldknode.PaymentDirection +import org.lightningdevkit.ldknode.PaymentStatus +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.TransactionSpeed +import to.bitkit.repositories.PaykitAllowanceLocalState.JournalEntry +import to.bitkit.repositories.PaykitAllowanceLocalState.Stage +import to.bitkit.services.PaykitSdkService +import to.bitkit.utils.AppError +import to.bitkit.utils.Logger +import to.bitkit.utils.ServiceError +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Instant + +/** An on-chain send failed; [isDefinitelyNotBroadcast] is true only when the transaction surely never left. */ +class PaykitAllowanceOnchainSendError( + val isDefinitelyNotBroadcast: Boolean, + cause: Throwable, +) : AppError(cause) + +/** The side effects of paying one request, kept apart so the admission logic is testable without a node. */ +@Singleton +@Suppress("TooManyFunctions") +class PaykitAllowancePayer @Inject constructor( + private val privatePaykitRepo: PrivatePaykitRepo, + private val paymentProofRepo: PaykitPaymentProofRepo, + private val lightningRepo: LightningRepo, +) { + suspend fun resolve( + request: PaykitPaymentRequest, + eligibleIdentifiers: List, + ): Result = privatePaykitRepo.resolveAllowancePayment(request, eligibleIdentifiers) + + suspend fun consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext): Result = + privatePaykitRepo.consumePrivatePaymentList(publicKey, context) + + suspend fun prepareProof( + request: PaykitPaymentRequest, + paymentEndpointIdentifier: String, + allowanceId: String?, + ): Result { + val kind = PaykitPaymentProofKind.fromPaymentEndpointIdentifier(paymentEndpointIdentifier) + ?: return Result.failure(PaykitPaymentRequestError.RequestUnavailable) + return paymentProofRepo.prepare(request, paymentEndpointIdentifier, kind, allowanceId) + } + + suspend fun associateLightningPayment( + request: PaykitPaymentRequest, + paymentHash: String, + paymentEndpointIdentifier: String, + ): Result = paymentProofRepo.associateLightningPayment(request, paymentHash, paymentEndpointIdentifier) + + suspend fun markOnchainPaymentStarted(request: PaykitPaymentRequest, address: String): Result = + paymentProofRepo.markOnchainPaymentStarted(request, address) + + suspend fun payLightning(bolt11: String, sats: ULong?): Result = lightningRepo.payInvoice(bolt11, sats) + + /** Sends at the medium fee rate. A failure is a [PaykitAllowanceOnchainSendError]. */ + suspend fun payOnchain(address: String, sats: ULong): Result { + var sendAttempted = false + var broadcastTxid: String? = null + val result = lightningRepo.sendOnChain( + address = address, + sats = sats, + speed = TransactionSpeed.Medium, + beforeSendAttempt = { sendAttempted = true }, + onBroadcast = { broadcastTxid = it }, + ) + broadcastTxid?.let { return Result.success(it) } + val error = result.exceptionOrNull() ?: return result + return Result.failure( + PaykitAllowanceOnchainSendError( + isDefinitelyNotBroadcast = !sendAttempted || error.isDefiniteOnchainPreBroadcastFailure(), + cause = error, + ), + ) + } + + suspend fun completeOnchainPayment( + request: PaykitPaymentRequest, + txid: String, + paymentEndpointIdentifier: String, + ) { + paymentProofRepo.completeOnchainPayment(request, txid, paymentEndpointIdentifier) + } + + /** Clears the proof when [error] proves the payment never left; returns whether it did. */ + suspend fun failLightningPayment(paymentHash: String, error: Throwable): Boolean = + paymentProofRepo.failLightningPayment(paymentHash, error) + + suspend fun failOnchainPayment(request: PaykitPaymentRequest) = paymentProofRepo.failOnchainPayment(request) + + suspend fun cancelProofPreparation(request: PaykitPaymentRequest) = paymentProofRepo.cancelPreparation(request) + + /** The node's status for an outbound payment, or null when the node does not know it (or is not running). */ + suspend fun lightningPaymentStatus(paymentHash: String): PaymentStatus? = + runSuspendCatching { lightningRepo.listPaymentsOrNull() } + .getOrNull() + ?.firstOrNull { it.direction == PaymentDirection.OUTBOUND && it.id.equals(paymentHash, ignoreCase = true) } + ?.status +} + +/** + * Runs Allowance admission for incoming requests through the SDK: evaluate, capacity preflight, automatic + * Acceptance, reserve, begin, pay, record the outcome. Every attempt is journaled before its handoff, so a restart + * resolves it from the node instead of paying again. + */ +@Singleton +@Suppress("TooManyFunctions") +class PaykitAllowanceExecutor @Inject constructor( + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val paykitSdkService: PaykitSdkService, + private val store: PaykitAllowanceStore, + private val payer: PaykitAllowancePayer, + private val clock: Clock, +) { + companion object { + private const val TAG = "PaykitAllowanceExecutor" + private const val MAX_JOURNAL_ENTRIES = 200 + private val SETTLEABLE_STAGES = setOf(Stage.SUBMITTED, Stage.SENDING, Stage.SENT, Stage.UNKNOWN) + } + + private var accountingAmount: (String, String) -> AccountingAmount = ::AccountingAmount + private val stateMutex = Mutex() + private val settleMutex = Mutex() + private val inFlightRequestIds = ConcurrentHashMap.newKeySet() + private val liveAttemptIds = ConcurrentHashMap.newKeySet() + private val _events = MutableSharedFlow(extraBufferCapacity = 16) + val events: SharedFlow = _events.asSharedFlow() + + @Volatile + var activeIdentity: String? = null + private set + + internal constructor( + ioDispatcher: CoroutineDispatcher, + paykitSdkService: PaykitSdkService, + store: PaykitAllowanceStore, + payer: PaykitAllowancePayer, + clock: Clock, + accountingAmount: (String, String) -> AccountingAmount, + ) : this(ioDispatcher, paykitSdkService, store, payer, clock) { + this.accountingAmount = accountingAmount + } + + fun activate(identity: String?) = run { activeIdentity = identity } + + // region Local state + + fun localState(identity: String): PaykitAllowanceLocalState = store.load(identity) + + suspend fun updateLocalState( + identity: String, + change: (PaykitAllowanceLocalState) -> PaykitAllowanceLocalState, + ): PaykitAllowanceLocalState = stateMutex.withLock { + val updated = change(localState(identity)) + runSuspendCatching { store.save(identity, updated) } + .onFailure { Logger.warn("Failed to save Paykit allowance state", it, context = TAG) } + updated + } + + fun isHandling(requestId: PaykitPaymentRequestId): Boolean = requestId in inFlightRequestIds + + /** + * Trusted time for eligibility: the real clock, never earlier than the last value given to the SDK, because the + * SDK refuses a watermark that moves backwards. + */ + suspend fun trustedTime(identity: String): String { + var millis = clock.now().toEpochMilliseconds() + updateLocalState(identity) { state -> + state.lastTrustedTimeMillis?.let { millis = maxOf(millis, it) } + state.copy(lastTrustedTimeMillis = millis) + } + return PaykitAllowanceTime.format(Instant.fromEpochMilliseconds(millis)) + } + + fun succeededAutomaticPayments(identity: String): List = + localState(identity).journal.filter { it.isAutomatic && it.stage == Stage.SUCCEEDED } + + // endregion + + // region Ledger + + /** + * Brings the SDK ledger to a reconciled state. A wallet with no ledger attests an empty history: it has never paid + * through an Allowance. After a restore, outcomes come from the journal and the node. + */ + suspend fun ensureReconciled(identity: String): AllowanceAccountingState = withContext(ioDispatcher) { + val current = paykitSdkService.allowanceAccountingState() + if (current != null && !current.requiresReconciliation) return@withContext current + + val history = current?.history ?: AllowanceAccountingHistory(emptyList(), emptyList(), emptyList()) + val outcomes = history.occurrences.flatMap { it.attempts }.mapNotNull { attempt -> + verifiedOutcome(attempt, identity)?.let { PaymentOutcomeReport(attempt.attemptId, it) } + } + val reconciled = paykitSdkService.reconcileAllowanceAccounting( + AllowanceAccountingReconciliation( + expectedRevision = current?.revision, + history = history, + outcomes = outcomes, + trustedTime = trustedTime(identity), + ), + ) + Logger.info("Reconciled Paykit allowance accounting with '${outcomes.size}' verified outcomes", context = TAG) + reconciled + } + + /** + * Resolves attempts a crash or kill left open. Prepared attempts never got a handoff and are released; submitted + * ones are settled from the journal and the node. Nothing is paid again, and attempts this process is still + * executing are left alone. + */ + suspend fun recover(identity: String) { + withContext(ioDispatcher) { recoverOpenAttempts(identity) } + } + + private suspend fun recoverOpenAttempts(identity: String) { + runSuspendCatching { + // No ledger means nothing was ever admitted. Creating one here would also end the rc55 storage layout. + paykitSdkService.allowanceAccountingState() ?: return@runSuspendCatching + val state = ensureReconciled(identity) + for (attempt in state.history.occurrences.flatMap { it.attempts }) { + if (attempt.attemptId in liveAttemptIds) continue + when (attempt.status) { + PaymentExecutionStatus.PREPARED -> { + if (attempt.epoch == state.epoch) record(attempt.attemptId, PaymentOutcome.FAILED, identity) + } + PaymentExecutionStatus.SUBMITTED, PaymentExecutionStatus.UNKNOWN -> { + val outcome = verifiedOutcome(attempt, identity) + ?: PaymentOutcome.UNKNOWN.takeIf { attempt.status == PaymentExecutionStatus.SUBMITTED } + outcome?.let { record(attempt.attemptId, it, identity) } + } + PaymentExecutionStatus.SUCCEEDED, PaymentExecutionStatus.FAILED -> Unit + } + } + }.onFailure { Logger.warn("Failed to recover Paykit allowance attempts", it, context = TAG) } + } + + /** Settles journaled Lightning attempts whose outcome the node already knows, for events missed while inactive. */ + suspend fun settleOpenLightningAttempts(): Unit = withContext(ioDispatcher) { + val identity = activeIdentity ?: return@withContext + val paymentHashes = localState(identity).journal + .filter { it.stage in SETTLEABLE_STAGES } + .mapNotNull { it.paymentHash } + .distinct() + for (paymentHash in paymentHashes) { + when (payer.lightningPaymentStatus(paymentHash)) { + PaymentStatus.SUCCEEDED -> lightningPaymentSettled(paymentHash, succeeded = true) + PaymentStatus.FAILED -> lightningPaymentSettled(paymentHash, succeeded = false) + PaymentStatus.PENDING, null -> Unit + } + } + } + + 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 journalOutcome(entry: JournalEntry): PaymentOutcome? = when (entry.stage) { + // The journal is written before the node call, so no payment left this wallet. + Stage.PREPARED, Stage.SUBMITTED, Stage.FAILED -> PaymentOutcome.FAILED + Stage.SUCCEEDED -> PaymentOutcome.SUCCEEDED + Stage.SENDING, Stage.SENT, Stage.UNKNOWN -> { + val paymentHash = entry.paymentHash + if (paymentHash == null) { + PaymentOutcome.SUCCEEDED.takeIf { entry.transactionId != null } + } else { + when (payer.lightningPaymentStatus(paymentHash)) { + PaymentStatus.SUCCEEDED -> PaymentOutcome.SUCCEEDED + PaymentStatus.FAILED -> PaymentOutcome.FAILED + PaymentStatus.PENDING, null -> null + } + } + } + } + + private suspend fun record(attemptId: String, outcome: PaymentOutcome, identity: String) { + paykitSdkService.recordPaymentOutcome(PaymentOutcomeReport(attemptId, outcome)) + val stage = when (outcome) { + PaymentOutcome.SUCCEEDED -> Stage.SUCCEEDED + PaymentOutcome.FAILED -> Stage.FAILED + PaymentOutcome.UNKNOWN -> Stage.UNKNOWN + } + updateJournalEntry(attemptId, identity) { it.copy(stage = stage) } + _events.tryEmit(PaykitAllowanceEvent.LedgerChanged) + } + + private suspend fun automaticAttempts(): List = + runSuspendCatching { paykitSdkService.allowanceAccountingState() } + .getOrNull() + ?.let { PaykitAllowanceCapacity.attempts(it.history) } + .orEmpty() + + // endregion + + // region Automatic payment + + suspend fun autoPay( + request: PaykitPaymentRequest, + allowances: List, + identity: String, + ): PaykitAllowanceAutoPayResult = withContext(ioDispatcher) { + val isCovered = request.direction == PaykitPaymentRequestDirection.Incoming && + request.billingPeriod == null && + request.lifecycleState == PaymentRequestLifecycleState.PROPOSED && + allowances.any { + it.isAllower && + it.lifecycleState == AllowanceLifecycleState.ACCEPTED && + PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) && + it.counterpartyReceiverPath == request.counterpartyReceiverPath + } + if (!isCovered || !inFlightRequestIds.add(request.id)) { + return@withContext PaykitAllowanceAutoPayResult.NOT_COVERED + } + + try { + runSuspendCatching { + ensureReconciled(identity) + admitAndPay(request, allowances, identity) + }.getOrElse { + Logger.warn("Kept an incoming request on the manual flow after an allowance error", it, context = TAG) + PaykitAllowanceAutoPayResult.MANUAL + } + } finally { + inFlightRequestIds.remove(request.id) + } + } + + @Suppress("ReturnCount", "LongMethod") + private suspend fun admitAndPay( + request: PaykitPaymentRequest, + allowances: List, + identity: String, + ): PaykitAllowanceAutoPayResult { + val scope = request.scope() + val selectionTime = trustedTime(identity) + val candidates = paykitSdkService.evaluateAllowanceCandidates(scope, selectionTime) + val candidate = candidates.firstOrNull { it.blocked == null } + val allowance = candidate?.let { eligible -> allowances.firstOrNull { it.allowanceId == eligible.allowanceId } } + if (candidate == null || allowance == null) { + val reasons = candidates.mapNotNull { it.blocked } + Logger.info("Kept an incoming request manual: no eligible allowance, blocked by '$reasons'", context = TAG) + return PaykitAllowanceAutoPayResult.MANUAL + } + + if (!PaykitAllowanceCapacity.fits(request.amountSats, allowance, automaticAttempts(), clock.now())) { + Logger.info("Kept an incoming request manual: the allowance limit is reached", context = TAG) + notifyLimitReached(request, identity) + return PaykitAllowanceAutoPayResult.MANUAL + } + + val payment = payer.resolve(request, candidate.eligiblePaymentEndpointIdentifiers).getOrThrow() + if (payment == null) { + Logger.info("Kept an incoming request manual: no payable private endpoint", context = TAG) + return PaykitAllowanceAutoPayResult.MANUAL + } + + val endpointIdentifier = payment.endpoint.methodId.rawValue + val association = paykitSdkService.acceptPaymentRequestAutomatically( + scope = scope, + selection = AllowanceSelectionInput( + allowanceId = candidate.allowanceId, + expectedRevision = null, + trustedTime = selectionTime, + ), + checks = checks(request, endpointIdentifier, selectionTime), + ) + sendQueuedMessages(request) + + val occurrence = PaymentOccurrence(scope, billingPeriod = null) + val reservation = paykitSdkService.reserveAutomaticPayment( + occurrence = occurrence, + expectedAssociationRevision = association.revisions.lastOrNull()?.revision ?: 1uL, + checks = checks(request, endpointIdentifier, trustedTime(identity)), + ) + val prepared = (reservation as? PaymentAttemptDecision.Ready)?.attempt + if (prepared == null) { + val reason = (reservation as? PaymentAttemptDecision.Blocked)?.reason + Logger.info("Kept an incoming request manual: the reservation is blocked by '$reason'", context = TAG) + runSuspendCatching { paykitSdkService.markPaymentManualOnly(occurrence) } + .onFailure { Logger.warn("Failed to mark an allowance payment manual only", it, context = TAG) } + notifyLimitReached(request, identity) + return PaykitAllowanceAutoPayResult.MANUAL + } + + liveAttemptIds.add(prepared.attemptId) + try { + return executeAutomaticAttempt(request, payment, prepared, identity) + } finally { + liveAttemptIds.remove(prepared.attemptId) + } + } + + private suspend fun executeAutomaticAttempt( + request: PaykitPaymentRequest, + payment: PrivatePaykitAllowancePayment, + prepared: PaymentAttemptRecord, + identity: String, + ): PaykitAllowanceAutoPayResult { + val endpointIdentifier = payment.endpoint.methodId.rawValue + journal( + JournalEntry( + attemptId = prepared.attemptId, + isAutomatic = true, + requestId = request.id, + allowanceId = prepared.allowanceId, + amountSats = request.amountSats, + paymentEndpointIdentifier = endpointIdentifier, + paymentHash = payment.lightningPaymentHash, + onchainAddress = payment.endpoint.value.takeIf { payment.endpoint.methodId.isOnchain }, + stage = Stage.PREPARED, + createdAtMillis = clock.now().toEpochMilliseconds(), + ), + identity, + ) + + // Begin fetches nothing, so pull the link first: an End or a cancellation must be seen before the handoff. + runSuspendCatching { + paykitSdkService.receivePrivateMessages(request.counterparty, request.counterpartyReceiverPath) + }.onFailure { Logger.warn("Failed to receive private messages before an allowance payment", it, context = TAG) } + val handoff = paykitSdkService.beginPaymentExecution( + attemptId = prepared.attemptId, + checks = checks(request, endpointIdentifier, trustedTime(identity)), + ) + val submitted = (handoff as? PaymentAttemptDecision.Ready)?.attempt + if (submitted == null) { + val reason = (handoff as? PaymentAttemptDecision.Blocked)?.reason + Logger.info("Kept an incoming request manual: the allowance handoff is blocked by '$reason'", context = TAG) + record(prepared.attemptId, PaymentOutcome.FAILED, identity) + return PaykitAllowanceAutoPayResult.MANUAL + } + setStage(Stage.SUBMITTED, submitted.attemptId, identity) + + runSuspendCatching { + payer.consumePaymentList(request.counterparty, payment.context).getOrThrow() + payer.prepareProof(request, endpointIdentifier, submitted.allowanceId).getOrThrow() + }.exceptionOrNull()?.let { + payer.cancelProofPreparation(request) + record(submitted.attemptId, PaymentOutcome.FAILED, identity) + throw it + } + + val paymentHash = payment.lightningPaymentHash + ?: return payOnchain(request, payment, submitted.attemptId, identity) + return payLightning(request, payment, paymentHash, submitted.attemptId, identity) + } + + private suspend fun payLightning( + request: PaykitPaymentRequest, + payment: PrivatePaykitAllowancePayment, + paymentHash: String, + attemptId: String, + identity: String, + ): PaykitAllowanceAutoPayResult { + payer.associateLightningPayment(request, paymentHash, payment.endpoint.methodId.rawValue).onFailure { + payer.cancelProofPreparation(request) + record(attemptId, PaymentOutcome.FAILED, identity) + throw it + } + + setStage(Stage.SENDING, attemptId, identity) + val sats = request.amountSats.takeUnless { payment.lightningInvoiceHasAmount } + payer.payLightning(payment.endpoint.value, sats).onFailure { + // Only an error that proves the payment never left releases the reservation; anything else stays open. + val neverSent = payer.failLightningPayment(paymentHash, it) + record(attemptId, if (neverSent) PaymentOutcome.FAILED else PaymentOutcome.UNKNOWN, identity) + throw it + } + // The node's payment event can settle the attempt before the send call returns; keep that outcome. + updateJournalEntry(attemptId, identity) { if (it.stage == Stage.SENDING) it.copy(stage = Stage.SENT) else it } + Logger.info("Handed an allowance payment to the node", context = TAG) + return PaykitAllowanceAutoPayResult.STARTED + } + + private suspend fun payOnchain( + request: PaykitPaymentRequest, + payment: PrivatePaykitAllowancePayment, + attemptId: String, + identity: String, + ): PaykitAllowanceAutoPayResult { + val address = payment.endpoint.value + payer.markOnchainPaymentStarted(request, address).onFailure { + payer.cancelProofPreparation(request) + record(attemptId, PaymentOutcome.FAILED, identity) + throw it + } + + setStage(Stage.SENDING, attemptId, identity) + val txid = payer.payOnchain(address, request.amountSats).getOrElse { + if ((it as? PaykitAllowanceOnchainSendError)?.isDefinitelyNotBroadcast == true) { + payer.failOnchainPayment(request) + record(attemptId, PaymentOutcome.FAILED, identity) + } else { + record(attemptId, PaymentOutcome.UNKNOWN, identity) + } + throw it + } + + updateJournalEntry(attemptId, identity) { it.copy(transactionId = txid) } + payer.completeOnchainPayment(request, txid, payment.endpoint.methodId.rawValue) + record(attemptId, PaymentOutcome.SUCCEEDED, identity) + _events.tryEmit(PaykitAllowanceEvent.PaidAutomatically(request.counterparty, request.amountSats, txid)) + Logger.info("Paid an allowance payment on-chain", context = TAG) + return PaykitAllowanceAutoPayResult.COMPLETED + } + + /** Called for every settled outbound Lightning payment; only journaled ones are Allowance work. */ + suspend fun lightningPaymentSettled(paymentHash: String, succeeded: Boolean): Unit = withContext(ioDispatcher) { + settleMutex.withLock { + val identity = activeIdentity ?: return@withLock + val entries = localState(identity).journal.filter { + it.paymentHash.equals(paymentHash, ignoreCase = true) && it.stage in SETTLEABLE_STAGES + } + for (entry in entries) { + runSuspendCatching { + val outcome = if (succeeded) PaymentOutcome.SUCCEEDED else PaymentOutcome.FAILED + record(entry.attemptId, outcome, identity) + if (succeeded && entry.isAutomatic) { + _events.tryEmit( + PaykitAllowanceEvent.PaidAutomatically( + counterparty = entry.requestId.counterparty, + amountSats = entry.amountSats, + paymentId = paymentHash.lowercase(), + ), + ) + } + }.onFailure { Logger.warn("Failed to record an allowance payment outcome", it, context = TAG) } + } + } + } + + // endregion + + // region Manual payments + + /** + * Reports a user-approved payment of an incoming request to the shared ledger before it leaves the wallet. + * Fails with [PaykitAllowanceError.PaymentAlreadyRecorded] when another live attempt exists for the request, so + * the same request is never paid twice. Any other problem leaves the payment unreported and returns null. + */ + suspend fun beginManualPayment( + request: PaykitPaymentRequest, + paymentEndpointIdentifier: String, + ): Result = withContext(ioDispatcher) { + val identity = activeIdentity + if ( + identity == null || + request.billingPeriod != null || + request.direction != PaykitPaymentRequestDirection.Incoming + ) { + return@withContext Result.success(null) + } + val result = runSuspendCatching { reportManualPayment(request, paymentEndpointIdentifier, identity) } + val error = result.exceptionOrNull() ?: return@withContext result + if (error is PaykitAllowanceError.PaymentAlreadyRecorded) return@withContext result + Logger.warn("Failed to report a manual payment to the allowance ledger", error, context = TAG) + Result.success(null) + } + + private suspend fun reportManualPayment( + request: PaykitPaymentRequest, + paymentEndpointIdentifier: String, + identity: String, + ): String? { + ensureReconciled(identity) + val decision = paykitSdkService.reserveManualPayment( + occurrence = PaymentOccurrence(request.scope(), billingPeriod = null), + checks = checks(request, paymentEndpointIdentifier, trustedTime(identity)), + ) + val prepared = when (decision) { + is PaymentAttemptDecision.Ready -> decision.attempt + is PaymentAttemptDecision.Blocked if decision.reason == AllowanceAccountingBlock.PaymentAlreadyRecorded -> + throw PaykitAllowanceError.PaymentAlreadyRecorded + is PaymentAttemptDecision.Blocked -> { + Logger.info("Skipped reporting a manual payment: blocked by '${decision.reason}'", context = TAG) + return null + } + } + // Recovery leaves the attempt alone until finishManualPayment reports its outcome. + liveAttemptIds.add(prepared.attemptId) + val attemptId = runSuspendCatching { + beginManualAttempt(request, paymentEndpointIdentifier, prepared, identity) + } + if (attemptId.getOrNull() == null) liveAttemptIds.remove(prepared.attemptId) + return attemptId.getOrThrow() + } + + private suspend fun beginManualAttempt( + request: PaykitPaymentRequest, + paymentEndpointIdentifier: String, + prepared: PaymentAttemptRecord, + identity: String, + ): String? { + journal( + JournalEntry( + attemptId = prepared.attemptId, + isAutomatic = false, + requestId = request.id, + allowanceId = null, + amountSats = request.amountSats, + paymentEndpointIdentifier = paymentEndpointIdentifier, + stage = Stage.PREPARED, + createdAtMillis = clock.now().toEpochMilliseconds(), + ), + identity, + ) + val handoff = paykitSdkService.beginPaymentExecution( + attemptId = prepared.attemptId, + checks = checks(request, paymentEndpointIdentifier, trustedTime(identity)), + ) + if (handoff !is PaymentAttemptDecision.Ready) { + record(prepared.attemptId, PaymentOutcome.FAILED, identity) + return null + } + setStage(Stage.SUBMITTED, prepared.attemptId, identity) + Logger.info("Reported a manual payment to the allowance ledger", context = TAG) + return prepared.attemptId + } + + suspend fun manualLightningPaymentSent(attemptId: String, paymentHash: String) { + val identity = activeIdentity ?: return + withContext(ioDispatcher) { + updateJournalEntry(attemptId, identity) { + it.copy(paymentHash = paymentHash.lowercase(), stage = Stage.SENT) + } + } + } + + suspend fun finishManualPayment( + attemptId: String, + outcome: PaymentOutcome, + transactionId: String? = null, + ) { + liveAttemptIds.remove(attemptId) + val identity = activeIdentity ?: return + withContext(ioDispatcher) { + settleMutex.withLock { + // The node's payment events may have settled a Lightning attempt already. + val stage = localState(identity).journal.firstOrNull { it.attemptId == attemptId }?.stage + if (stage == Stage.SUCCEEDED || stage == Stage.FAILED) return@withLock + transactionId?.let { txid -> updateJournalEntry(attemptId, identity) { it.copy(transactionId = txid) } } + runSuspendCatching { record(attemptId, outcome, identity) } + .onFailure { Logger.warn("Failed to record a manual payment outcome", it, context = TAG) } + } + } + } + + // endregion + + // region Helpers + + private fun checks( + request: PaykitPaymentRequest, + endpointIdentifier: String, + trustedTime: String, + ) = PaymentExecutionChecks( + trustedTime = trustedTime, + paymentEndpointIdentifier = endpointIdentifier, + actualAmount = accountingAmount(request.amountValue, PaykitIssuerInterop.BITCOIN_ASSET), + endpointCurrent = true, + localEnabled = true, + recurrenceEligible = true, + ) + + private suspend fun sendQueuedMessages(request: PaykitPaymentRequest) { + runSuspendCatching { + paykitSdkService.processOutboundPrivateMessages(request.counterparty, request.counterpartyReceiverPath) + }.onFailure { Logger.warn("Failed to send the automatic acceptance right away", it, context = TAG) } + } + + private suspend fun journal(entry: JournalEntry, identity: String) { + updateLocalState(identity) { state -> + state.copy( + journal = (state.journal.filterNot { it.attemptId == entry.attemptId } + entry) + .takeLast(MAX_JOURNAL_ENTRIES), + ) + } + } + + private suspend fun setStage(stage: Stage, attemptId: String, identity: String) { + updateJournalEntry(attemptId, identity) { it.copy(stage = stage) } + } + + private suspend fun updateJournalEntry( + attemptId: String, + identity: String, + change: (JournalEntry) -> JournalEntry, + ) { + updateLocalState(identity) { state -> + state.copy(journal = state.journal.map { if (it.attemptId == attemptId) change(it) else it }) + } + } + + private suspend fun notifyLimitReached(request: PaykitPaymentRequest, identity: String) { + var isNew = false + updateLocalState(identity) { state -> + isNew = request.paymentRequestId !in state.notifiedRequestIds + state.copy(notifiedRequestIds = state.notifiedRequestIds + request.paymentRequestId) + } + if (isNew) _events.tryEmit(PaykitAllowanceEvent.LimitReached(request.counterparty, request.amountSats)) + } + + // endregion +} + +private fun PaykitPaymentRequest.scope() = + PaymentRequestScope(counterparty, counterpartyReceiverPath, paymentRequestId) + +private fun Throwable.isDefiniteOnchainPreBroadcastFailure(): Boolean = + generateSequence(this as Throwable?) { it.cause } + .any { + it is ServiceError.NodeNotSetup || + it is ServiceError.NodeNotStarted || + it is NodeException.NotRunning || + it is NodeException.OnchainTxCreationFailed || + it is NodeException.OnchainTxSigningFailed || + it is NodeException.WalletOperationFailed || + it is NodeException.PersistenceFailed || + it is NodeException.InvalidAddress || + it is NodeException.InvalidAmount || + it is NodeException.InvalidNetwork || + it is NodeException.InvalidFeeRate || + it is NodeException.InsufficientFunds || + it is NodeException.CoinSelectionFailed || + it is NodeException.NoSpendableOutputs + } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt index 0a38837efa..a73663ac00 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -1,26 +1,123 @@ package to.bitkit.repositories -import kotlinx.coroutines.flow.MutableSharedFlow +import com.synonym.paykit.AllowanceFilter +import com.synonym.paykit.AllowanceHistoryStatus +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.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.Network +import to.bitkit.async.appScope +import to.bitkit.di.IoDispatcher +import to.bitkit.env.Env +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.safe +import to.bitkit.services.PaykitReceiverPaths +import to.bitkit.services.PaykitSdkService +import to.bitkit.utils.AppError +import to.bitkit.utils.Logger +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import javax.inject.Singleton - -// CONTRACT (allowances port): the public API below is fixed; bodies are filled by the core worker. +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant sealed interface PaykitAllowanceEvent { - data class PaidAutomatically(val counterparty: String, val amountSats: ULong, val paymentId: String) : PaykitAllowanceEvent + data class PaidAutomatically( + val counterparty: String, + val amountSats: ULong, + val paymentId: String, + ) : PaykitAllowanceEvent + data class LimitReached(val counterparty: String, val amountSats: ULong) : PaykitAllowanceEvent + data object LedgerChanged : PaykitAllowanceEvent } -enum class PaykitAllowanceAutoPayResult { NOT_COVERED, MANUAL, STARTED, COMPLETED } +enum class PaykitAllowanceAutoPayResult { + /** No accepted Allowance covers the request's link, or the request is already being paid. */ + NOT_COVERED, + + /** An Allowance exists but this request stays on the manual flow (over a limit, ended, no payable endpoint). */ + MANUAL, + + /** The Lightning payment was handed to the node; the outcome arrives through the node's payment events. */ + STARTED, + + /** The on-chain payment was broadcast and recorded. */ + COMPLETED, +} + +sealed class PaykitAllowanceError(message: String) : AppError(message) { + data object ContactNotLinked : PaykitAllowanceError("The contact has no linked Paykit receiver") + data object Unavailable : PaykitAllowanceError("The allowance is unavailable") + data object PaymentAlreadyRecorded : PaykitAllowanceError("A payment for this request is already in progress") +} +/** + * Allowances for the active identity. One user-facing entry groups the same grant across a contact's links (their + * wallet, and their Paykit Server folder), and covered incoming requests are paid headlessly through + * [PaykitAllowanceExecutor]. The repository also settles its own Lightning attempts from the node's payment events + * and attributes automatic payments to the contact's activity. + */ @Singleton -class PaykitAllowanceRepo @Inject constructor() { +@Suppress("TooManyFunctions", "LongParameterList") +class PaykitAllowanceRepo @Inject constructor( + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val paykitSdkService: PaykitSdkService, + private val executor: PaykitAllowanceExecutor, + private val lightningRepo: LightningRepo, + private val activityRepo: ActivityRepo, + private val clock: Clock, +) { + companion object { + private const val TAG = "PaykitAllowanceRepo" + + /** A request created up to this long before its Allowance was accepted still counts as created after it. */ + val ACCEPTANCE_CLOCK_TOLERANCE = 30.seconds + + /** Endpoints Bitkit pays automatically: bolt11 and every on-chain method of the network. */ + fun allowedPaymentEndpointIdentifiers(network: Network = Env.network): List = + (listOf(MethodId.Bolt11) + MethodId.entries.filter { it.isOnchain }).map { it.rawValueForNetwork(network) } + + /** The supported receiver paths among [paths], the wallet link first. */ + fun orderedReceiverPaths(paths: Collection): List = + PaykitReceiverPaths.ordered.filter { it in paths } + } + + private val scope = appScope(ioDispatcher, TAG) + private val refreshMutex = Mutex() + private val publishLock = Any() + private val isProcessingRequests = AtomicBoolean(false) + private val manualRequestSignatures = ConcurrentHashMap() + private val _allowances = MutableStateFlow>(emptyList()) + private val _localState = MutableStateFlow(PaykitAllowanceLocalState()) + private val _autoPaidRequestIds = MutableStateFlow>(emptySet()) + private var allowanceTerms: (PaykitAllowanceLimits, Instant, List) -> AllowanceTerms = + { limits, monthAnchor, endpoints -> limits.terms(monthAnchor, endpoints) } + + @Volatile + private var activeIdentity: String? = null + private val _entries = MutableStateFlow>(emptyList()) /** Grants grouped across a contact's links, newest first. */ @@ -31,47 +128,386 @@ class PaykitAllowanceRepo @Inject constructor() { /** Sats paid automatically per entry id, for "Paid so far". */ val autoPaidSats: StateFlow> = _autoPaidSats.asStateFlow() - private val _events = MutableSharedFlow(extraBufferCapacity = 16) - val events: SharedFlow = _events.asSharedFlow() + val events: SharedFlow = executor.events + + internal constructor( + ioDispatcher: CoroutineDispatcher, + paykitSdkService: PaykitSdkService, + executor: PaykitAllowanceExecutor, + lightningRepo: LightningRepo, + activityRepo: ActivityRepo, + clock: Clock, + allowanceTerms: (PaykitAllowanceLimits, Instant, List) -> AllowanceTerms, + ) : this(ioDispatcher, paykitSdkService, executor, lightningRepo, activityRepo, clock) { + this.allowanceTerms = allowanceTerms + } + + init { + scope.launch { executor.events.collect { onAllowanceEvent(it) } } + scope.launch { lightningRepo.nodeEvents.collect { onNodeEvent(it) } } + scope.launch { + lightningRepo.lightningState + .map { it.nodeLifecycleState.isRunning() } + .distinctUntilChanged() + .filter { it } + .collect { executor.settleOpenLightningAttempts() } + } + } fun entry(id: String): PaykitAllowanceEntry? = _entries.value.firstOrNull { it.id == id } - suspend fun activate(identity: String?): Unit = TODO() + // region Lifecycle + + suspend fun activate(identity: String?) { + val normalizedIdentity = identity?.let(PubkyPublicKeyFormat::normalized) + if (normalizedIdentity == null) { + deactivate() + return + } + withContext(ioDispatcher) { + val identityChanged = activeIdentity != normalizedIdentity + activeIdentity = normalizedIdentity + executor.activate(normalizedIdentity) + if (identityChanged) { + manualRequestSignatures.clear() + executor.recover(normalizedIdentity) + } + refresh() + } + } + + fun deactivate() { + activeIdentity = null + executor.activate(null) + manualRequestSignatures.clear() + publish(emptyList(), PaykitAllowanceLocalState()) + } - fun deactivate(): Unit = TODO() + suspend fun refresh(): Result = withContext(ioDispatcher) { + val identity = activeIdentity ?: return@withContext Result.success(Unit) + refreshMutex.withLock { + val listing = runSuspendCatching { + paykitSdkService.listAllowances( + AllowanceFilter( + counterparty = null, + counterpartyReceiverPath = null, + localRole = null, + states = emptyList(), + ), + ) + .filter { + it.historyStatus == AllowanceHistoryStatus.CONSISTENT || + it.historyStatus == AllowanceHistoryStatus.UNRESOLVED_REFERENCES + } + .mapNotNull { PaykitAllowance.from(it) } + }.onFailure { Logger.warn("Failed to list Paykit allowances", it, context = TAG) } + if (activeIdentity == identity) { + publish(listing.getOrDefault(_allowances.value), executor.localState(identity)) + } + listing.map {} + } + } - suspend fun refresh(): Result = TODO() + /** Proposes the same terms on every supported linked receiver path of the contact and records one entry. */ + suspend fun propose(contactPublicKey: String, limits: PaykitAllowanceLimits): Result = + withContext(ioDispatcher) { + runSuspendCatching { + val identity = activeIdentity ?: throw PaykitAllowanceError.Unavailable + val contactKey = PubkyPublicKeyFormat.normalized(contactPublicKey) + ?: throw PaykitAllowanceError.ContactNotLinked + val linkedPaths = paykitSdkService.linkedPeers() + .filter { + PubkyPublicKeyFormat.matches(it.counterparty, contactKey) && it.state == LinkedPeerState.LINKED + } + .map { it.counterpartyReceiverPath } + val receiverPaths = orderedReceiverPaths(linkedPaths) + if (receiverPaths.isEmpty()) throw PaykitAllowanceError.ContactNotLinked - suspend fun propose(contactPublicKey: String, limits: PaykitAllowanceLimits): Result = TODO() + val terms = allowanceTerms( + limits, + PaykitAllowanceTime.monthStart(clock.now()), + allowedPaymentEndpointIdentifiers(), + ) + val allowanceIds = proposeOnLinks(contactKey, receiverPaths, terms) + val group = PaykitAllowanceLocalState.Group( + id = UUID.randomUUID().toString(), + counterparty = contactKey, + limits = limits, + allowanceIds = allowanceIds, + createdAtMillis = clock.now().toEpochMilliseconds(), + ) + executor.updateLocalState(identity) { it.copy(groups = it.groups + group) } + Logger.info("Proposed an allowance on '${allowanceIds.size}' links", context = TAG) + refresh() + Unit + } + } - suspend fun accept(entryId: String): Result = TODO() + suspend fun accept(entryId: String): Result = respond(entryId) { + paykitSdkService.acceptAllowance(it.counterparty, it.counterpartyReceiverPath, it.allowanceId) + } - suspend fun reject(entryId: String): Result = TODO() + suspend fun reject(entryId: String): Result = respond(entryId) { + paykitSdkService.rejectAllowance(it.counterparty, it.counterpartyReceiverPath, it.allowanceId) + } - suspend fun end(entryId: String): Result = TODO() + suspend fun end(entryId: String): Result = withContext(ioDispatcher) { + runSuspendCatching { + val entry = entry(entryId) ?: throw PaykitAllowanceError.Unavailable + val endable = entry.allowances.filter { it.canEnd } + if (endable.isEmpty()) throw PaykitAllowanceError.Unavailable + for (allowance in endable) { + paykitSdkService.endAllowance( + allowance.counterparty, + allowance.counterpartyReceiverPath, + allowance.allowanceId, + ) + sendQueuedMessages(allowance.counterparty, allowance.counterpartyReceiverPath) + } + refresh() + Unit + } + } - fun proposalForPresentation(): PaykitAllowanceEntry? = TODO() + private suspend fun respond( + entryId: String, + response: suspend (PaykitAllowance) -> AllowanceRecord, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + val identity = activeIdentity ?: throw PaykitAllowanceError.Unavailable + val entry = entry(entryId) ?: throw PaykitAllowanceError.Unavailable + val answerable = entry.allowances.filter { it.isAnswerable } + if (answerable.isEmpty()) throw PaykitAllowanceError.Unavailable + for (allowance in answerable) { + response(allowance) + sendQueuedMessages(allowance.counterparty, allowance.counterpartyReceiverPath) + } + val ids = entry.allowances.map { it.allowanceId } + executor.updateLocalState(identity) { it.copy(presentedProposalIds = it.presentedProposalIds + ids) } + refresh() + Unit + } + } - suspend fun markProposalPresented(entryId: String): Unit = TODO() + private suspend fun proposeOnLinks( + contactKey: String, + receiverPaths: List, + terms: AllowanceTerms, + ): List { + val allowanceIds = mutableListOf() + var firstError: Throwable? = null + for (receiverPath in receiverPaths) { + runSuspendCatching { + paykitSdkService.proposeAllowance(contactKey, receiverPath, AllowanceLocalRole.ALLOWER, terms) + }.onSuccess { + allowanceIds += it.allowanceId + sendQueuedMessages(contactKey, receiverPath) + }.onFailure { + if (receiverPath == PaykitReceiverPaths.WALLET) throw it + if (firstError == null) firstError = it + Logger.warn("Failed to propose an allowance on the secondary link '$receiverPath'", it, context = TAG) + } + } + if (allowanceIds.isEmpty()) throw firstError ?: PaykitAllowanceError.Unavailable + return allowanceIds + } + + private suspend fun sendQueuedMessages(counterparty: String, receiverPath: String) { + runSuspendCatching { paykitSdkService.processOutboundPrivateMessages(counterparty, receiverPath) } + .onFailure { + Logger.warn("Failed to send allowance messages on '$receiverPath' right away", it, context = TAG) + } + } + + // endregion + + // region Presentation + + /** The first received proposal that still needs an answer and was not shown yet. */ + fun proposalForPresentation(): PaykitAllowanceEntry? { + val presented = _localState.value.presentedProposalIds + return _entries.value.firstOrNull { entry -> + entry.isAnswerable && entry.allowances.none { it.allowanceId in presented } + } + } + + suspend fun markProposalPresented(entryId: String) { + val identity = activeIdentity ?: return + val ids = entry(entryId)?.allowances?.map { it.allowanceId } ?: return + val state = withContext(ioDispatcher) { + executor.updateLocalState(identity) { it.copy(presentedProposalIds = it.presentedProposalIds + ids) } + } + if (activeIdentity == identity) publish(_allowances.value, state) + } + + // endregion + + // region Automatic payments + + /** + * Whether an active Allowance this wallet granted covers the request's exact link. A request created before the + * Allowance was accepted stays manual: it may already have been shown to the user for a decision. + */ + fun coversRequest(request: PaykitPaymentRequest): Boolean { + val now = clock.now() + return _allowances.value.any { allowance -> + allowance.isAllower && + allowance.status(now) == PaykitAllowance.Status.ACTIVE && + PubkyPublicKeyFormat.matches(allowance.counterparty, request.counterparty) && + allowance.counterpartyReceiverPath == request.counterpartyReceiverPath && + isCreatedAfterAcceptance(request, allowance) + } + } /** Pays covered incoming requests headlessly; returns true when any request was handled. */ - suspend fun processIncomingRequests(requests: List): Boolean = TODO() + suspend fun processIncomingRequests(requests: List): Boolean = withContext(ioDispatcher) { + val identity = activeIdentity ?: return@withContext false + val signature = allowancesSignature() + val covered = requests.filter { + it.requiresAcceptance && coversRequest(it) && manualRequestSignatures[it.id] != signature + } + if (covered.isEmpty() || !isProcessingRequests.compareAndSet(false, true)) return@withContext false - /** True while a request is covered by an active allowance or is being paid automatically: keep it off the Send sheet. */ - suspend fun isAutomaticallyHandling(request: PaykitPaymentRequest): Boolean = TODO() + try { + payCovered(covered, identity, signature) + } finally { + isProcessingRequests.set(false) + } + } + + /** + * True while a request is covered by an active allowance or is being paid automatically: keep it off the Send + * sheet. A covered request counts until processIncomingRequests has found it manual. + */ + suspend fun isAutomaticallyHandling(request: PaykitPaymentRequest): Boolean { + if (executor.isHandling(request.id)) return true + return request.requiresAcceptance && + coversRequest(request) && + manualRequestSignatures[request.id] != allowancesSignature() + } /** Request ids paid automatically, for the "Auto-paid" tag in payment history. */ - fun isAutoPaid(id: PaykitPaymentRequestId): Boolean = TODO() + fun isAutoPaid(id: PaykitPaymentRequestId): Boolean = id in _autoPaidRequestIds.value + + private suspend fun payCovered( + covered: List, + identity: String, + signature: Int, + ): Boolean { + var handledAny = false + for (request in covered) { + val result = executor.autoPay(request, _allowances.value, identity) + Logger.info("Decided '$result' for an incoming request covered by an allowance", context = TAG) + when (result) { + PaykitAllowanceAutoPayResult.STARTED, PaykitAllowanceAutoPayResult.COMPLETED -> handledAny = true + PaykitAllowanceAutoPayResult.MANUAL -> manualRequestSignatures[request.id] = signature + PaykitAllowanceAutoPayResult.NOT_COVERED -> Unit + } + } + if (handledAny) refresh() + return handledAny + } - /** Reports a manual payment of a request to the allowance ledger; returns the attempt id or null when no ledger applies. */ - suspend fun beginManualPayment(request: PaykitPaymentRequest, paymentEndpointIdentifier: String): Result = TODO() + private fun isCreatedAfterAcceptance(request: PaykitPaymentRequest, allowance: PaykitAllowance): Boolean { + val acceptedAt = allowance.lastEventAt ?: return true + val createdAt = request.createdAt ?: return true + return createdAt >= acceptedAt - ACCEPTANCE_CLOCK_TOLERANCE + } - suspend fun manualLightningPaymentSent(attemptId: String, paymentHash: String): Unit = TODO() + private fun allowancesSignature(): Int { + val lifecycle = _allowances.value.map { it.allowanceId to it.lifecycleState } + val autoPaidTotal = _autoPaidSats.value.values.fold(0uL) { total, sats -> total.safe() + sats.safe() } + return listOf(lifecycle, autoPaidTotal).hashCode() + } + + // endregion + + // region Ledger + + /** + * Reports a manual payment of a request to the allowance ledger; returns the attempt id or null when no ledger + * applies. Fails with [PaykitAllowanceError.PaymentAlreadyRecorded] while another attempt for the request is live. + */ + suspend fun beginManualPayment(request: PaykitPaymentRequest, paymentEndpointIdentifier: String): Result = + executor.beginManualPayment(request, paymentEndpointIdentifier) + + suspend fun manualLightningPaymentSent(attemptId: String, paymentHash: String) = + executor.manualLightningPaymentSent(attemptId, paymentHash) /** [succeeded] null = outcome unknown (pending). */ - suspend fun finishManualPayment(attemptId: String, succeeded: Boolean?, transactionId: String? = null): Unit = TODO() + suspend fun finishManualPayment(attemptId: String, succeeded: Boolean?, transactionId: String? = null) { + val outcome = when (succeeded) { + true -> PaymentOutcome.SUCCEEDED + false -> PaymentOutcome.FAILED + null -> PaymentOutcome.UNKNOWN + } + executor.finishManualPayment(attemptId, outcome, transactionId) + } + + /** The repository already settles from the node's payment events; extra calls for the same hash are no-ops. */ + suspend fun lightningPaymentSettled(paymentHash: String, succeeded: Boolean) = + executor.lightningPaymentSettled(paymentHash, succeeded) + + suspend fun recover() { + val identity = activeIdentity ?: return + executor.recover(identity) + } + + private suspend fun onNodeEvent(event: Event) { + when (event) { + is Event.PaymentSuccessful -> executor.lightningPaymentSettled(event.paymentHash, succeeded = true) + is Event.PaymentFailed -> (event.paymentHash ?: event.paymentId)?.let { + executor.lightningPaymentSettled(it, succeeded = false) + } + else -> Unit + } + } + + private suspend fun onAllowanceEvent(event: PaykitAllowanceEvent) { + when (event) { + is PaykitAllowanceEvent.PaidAutomatically -> { + activityRepo.setContact(event.counterparty, event.paymentId) + reloadLocalState() + } + PaykitAllowanceEvent.LedgerChanged -> reloadLocalState() + is PaykitAllowanceEvent.LimitReached -> Unit + } + } + + private fun reloadLocalState() { + val identity = activeIdentity ?: return + publish(_allowances.value, executor.localState(identity)) + } - suspend fun lightningPaymentSettled(paymentHash: String, succeeded: Boolean): Unit = TODO() + // endregion - suspend fun recover(): Unit = TODO() + private fun publish( + allowances: List, + state: PaykitAllowanceLocalState, + ) = synchronized(publishLock) { + val entries = allowances + .groupBy { state.group(containing = it.allowanceId)?.id ?: it.allowanceId } + .map { (id, members) -> + PaykitAllowanceEntry( + id = id, + allowances = members, + limits = state.groups.firstOrNull { it.id == id }?.limits, + ) + } + val paid = state.journal.filter { it.isAutomatic && it.stage == PaykitAllowanceLocalState.Stage.SUCCEEDED } + val paidByAllowance = paid.groupBy { it.allowanceId }.mapValues { (_, payments) -> + payments.fold(0uL) { total, payment -> total.safe() + payment.amountSats.safe() } + } + _allowances.update { allowances } + _localState.update { state } + _autoPaidRequestIds.update { paid.mapTo(mutableSetOf()) { it.requestId } } + _entries.update { entries } + _autoPaidSats.update { + entries.associate { entry -> + entry.id to entry.allowances.fold(0uL) { total, allowance -> + total.safe() + (paidByAllowance[allowance.allowanceId] ?: 0uL).safe() + } + } + } + } } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt new file mode 100644 index 0000000000..fe485e6639 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt @@ -0,0 +1,92 @@ +package to.bitkit.repositories + +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import to.bitkit.data.keychain.Keychain +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.utils.Logger +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Local Allowance state kept per identity: USD labels, the grouping of one grant across a contact's links, and the + * execution journal that restart recovery reads. The SDK ledger stays authoritative for admission. + */ +@Serializable +data class PaykitAllowanceLocalState( + val groups: List = emptyList(), + val journal: List = emptyList(), + val presentedProposalIds: Set = emptySet(), + val notifiedRequestIds: Set = emptySet(), + val lastTrustedTimeMillis: Long? = null, +) { + @Serializable + data class Group( + val id: String, + val counterparty: String, + val limits: PaykitAllowanceLimits, + val allowanceIds: List, + val createdAtMillis: Long, + ) + + @Serializable + enum class Stage { PREPARED, SUBMITTED, SENDING, SENT, SUCCEEDED, FAILED, UNKNOWN } + + @Serializable + data class JournalEntry( + val attemptId: String, + val isAutomatic: Boolean, + val requestId: PaykitPaymentRequestId, + val allowanceId: String?, + val amountSats: ULong, + val paymentEndpointIdentifier: String, + val paymentHash: String? = null, + val onchainAddress: String? = null, + val transactionId: String? = null, + val stage: Stage, + val createdAtMillis: Long, + ) + + fun group(containing: String): Group? = groups.firstOrNull { containing in it.allowanceIds } +} + +@Singleton +class PaykitAllowanceStore @Inject constructor( + private val keychain: Keychain, +) { + companion object { + private const val TAG = "PaykitAllowanceStore" + private val KEY = Keychain.Key.PAYKIT_ALLOWANCE_STATE.name + private val storeJson = Json { ignoreUnknownKeys = true } + } + + @Serializable + private data class Stored( + val statesByIdentity: Map = emptyMap(), + ) + + fun load(identity: String): PaykitAllowanceLocalState = + runCatching { loadAll().statesByIdentity[storageKey(identity)] } + .onFailure { Logger.warn("Failed to load Paykit allowance state", it, context = TAG) } + .getOrNull() + ?: PaykitAllowanceLocalState() + + suspend fun save(identity: String, state: PaykitAllowanceLocalState) { + val stored = loadAll() + val updated = stored.copy(statesByIdentity = stored.statesByIdentity + (storageKey(identity) to state)) + keychain.upsertString(KEY, storeJson.encodeToString(updated)) + } + + private fun loadAll(): Stored { + val value = keychain.loadString(KEY) ?: return Stored() + return runCatching { storeJson.decodeFromString(value) } + .getOrElse { + Logger.warn("Discarded corrupt Paykit allowance state", it, context = TAG) + Stored() + } + } + + private fun storageKey(identity: String): String = PubkyPublicKeyFormat.normalized(identity) ?: identity +} diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 980709b872..a168fe33e8 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -66,6 +66,8 @@ data class PendingPaykitPaymentProof( val onchainAmountSats: ULong? = null, val onchainWalletId: String = WalletScope.default, val onchainMatchingTransactionIdsBeforeAttempt: Set = emptySet(), + /** Set only for an automatic Allowance payment, from its submitted attempt. Manual payments omit it. */ + val allowanceId: String? = null, ) data class PaykitOnchainPaymentProofResolution( @@ -126,10 +128,18 @@ class PaykitPaymentProofRepo @Inject constructor( request: PaykitPaymentRequest, paymentEndpointIdentifier: String, kind: PaykitPaymentProofKind, + ): Result = prepare(request, paymentEndpointIdentifier, kind, allowanceId = null) + + /** [allowanceId] is set only for an automatic Allowance payment; the submitted proof carries it. */ + suspend fun prepare( + request: PaykitPaymentRequest, + paymentEndpointIdentifier: String, + kind: PaykitPaymentProofKind, + allowanceId: String?, ): Result = withContext(ioDispatcher) { runSuspendCatching { operationMutex.withLock { - val proof = pendingProof(request, paymentEndpointIdentifier, kind) + val proof = pendingProof(request, paymentEndpointIdentifier, kind).copy(allowanceId = allowanceId) val currentProofs = loadProofs() if (currentProofs.any { it.isStartedFor(proof.identity, request.id) }) { throw PaykitPaymentRequestError.OperationInProgress @@ -522,6 +532,7 @@ class PaykitPaymentProofRepo @Inject constructor( paymentEndpointIdentifier = proof.paymentEndpointIdentifier, proofJson = proofJson, billingPeriod = proof.billingPeriod, + allowanceId = proof.allowanceId, ) Logger.info("Queued a Paykit payment proof for private delivery", context = TAG) runSuspendCatching { paykitSdkService.processPendingPrivateMessages() } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 83d8e0f582..8b6fc45f95 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -385,6 +385,78 @@ class PrivatePaykitRepo @Inject constructor( result } + /** + * Resolves the payee's current private endpoint for an automatic Allowance payment. Only endpoints that the + * Allowance and the request both accept qualify, and only ones this wallet can pay without asking: a bolt11 + * invoice for exactly the requested amount (or amount-less), or an unused on-chain address. Public endpoints + * are never used. Returns null when nothing qualifies. + */ + suspend fun resolveAllowancePayment( + request: PaykitPaymentRequest, + eligibleIdentifiers: List, + ): Result = withContext(serializedDispatcher) { + runSuspendCatching { + if (request.isExpired(clock.now())) return@runSuspendCatching null + val publicKey = normalizedPublicKey(request.counterparty) ?: return@runSuspendCatching null + val consumedVersion = ensureState().contacts[publicKey] + ?.consumedPrivatePaymentListVersionsByReceiverPath + ?.get(request.counterpartyReceiverPath) + val prepared = paykitSdkService.prepareAndResolvePrivateContactPayment( + counterparty = publicKey, + receiverPath = request.counterpartyReceiverPath, + afterPrivatePaymentListVersion = consumedVersion, + amount = PaymentAmountContext(request.amountValue, PaykitIssuerInterop.BITCOIN_ASSET), + ) + val paymentListVersion = prepared.resolution.privatePaymentListVersion + ?: return@runSuspendCatching null + + val eligible = eligibleIdentifiers.toSet() intersect request.acceptedPaymentEndpointIdentifiers.toSet() + val candidates = prepared.resolution.payableEndpoints + .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload) } + .filter { + it.methodId.rawValue in eligible && (it.methodId == MethodId.Bolt11 || it.methodId.isOnchain) + } + allowancePayment( + request = request, + payable = privatePayableEndpoints(candidates, publicKey), + context = PrivatePaykitPaymentContext(request.counterpartyReceiverPath, paymentListVersion), + ) + } + } + + private suspend fun allowancePayment( + request: PaykitPaymentRequest, + payable: List, + context: PrivatePaykitPaymentContext, + ): PrivatePaykitAllowancePayment? = PublicPaykitRepo.payablePreferenceOrder + .mapNotNull { methodId -> payable.firstOrNull { it.methodId == methodId } } + .firstNotNullOfOrNull { allowancePayment(request, it, context) } + + private suspend fun allowancePayment( + request: PaykitPaymentRequest, + endpoint: Endpoint, + context: PrivatePaykitPaymentContext, + ): PrivatePaykitAllowancePayment? { + if (endpoint.methodId != MethodId.Bolt11) { + return PrivatePaykitAllowancePayment( + endpoint = endpoint, + context = context, + lightningPaymentHash = null, + lightningInvoiceHasAmount = false, + ) + } + val invoice = runSuspendCatching { (coreService.decode(endpoint.value) as? Scanner.Lightning)?.invoice } + .getOrNull() + ?: return null + if (!request.acceptsLightningInvoiceAmountSats(invoice.amountSatoshis)) return null + return PrivatePaykitAllowancePayment( + endpoint = endpoint, + context = context, + lightningPaymentHash = invoice.paymentHash.toHex().lowercase(), + lightningInvoiceHasAmount = invoice.amountSatoshis != 0uL, + ) + } + suspend fun consumePrivatePaymentList( publicKey: String, context: PrivatePaykitPaymentContext, diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index b55fff7326..c7e1f86ed7 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -86,6 +86,14 @@ data class PrivatePaykitPaymentContext( val paymentListVersion: ULong, ) +/** The payee's current private endpoint for an automatic Allowance payment. */ +data class PrivatePaykitAllowancePayment( + val endpoint: Endpoint, + val context: PrivatePaykitPaymentContext, + val lightningPaymentHash: String?, + val lightningInvoiceHasAmount: Boolean, +) + @OptIn(ExperimentalTime::class) @Suppress("LongParameterList") @Singleton diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt new file mode 100644 index 0000000000..6d364491f1 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -0,0 +1,798 @@ +package to.bitkit.repositories + +import com.synonym.paykit.AllowanceAccountingBlock +import com.synonym.paykit.AllowanceAccountingReconciliation +import com.synonym.paykit.AllowanceAccountingState +import com.synonym.paykit.AllowanceAssociationRecord +import com.synonym.paykit.AllowanceAssociationRevision +import com.synonym.paykit.AllowanceCandidate +import com.synonym.paykit.AllowanceLifecycleState +import com.synonym.paykit.AllowanceSelectionInput +import com.synonym.paykit.OutboundPrivateSendReport +import com.synonym.paykit.PaymentAttemptDecision +import com.synonym.paykit.PaymentDisposition +import com.synonym.paykit.PaymentExecutionChecks +import com.synonym.paykit.PaymentExecutionMode +import com.synonym.paykit.PaymentExecutionStatus +import com.synonym.paykit.PaymentOccurrence +import com.synonym.paykit.PaymentOccurrenceKey +import com.synonym.paykit.PaymentOccurrenceRecord +import com.synonym.paykit.PaymentOutcome +import com.synonym.paykit.PaymentOutcomeReport +import com.synonym.paykit.PaymentRequestScope +import com.synonym.paykit.PrivateStreamIntakeReport +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import org.junit.Before +import org.junit.Test +import org.lightningdevkit.ldknode.PaymentStatus +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import to.bitkit.data.keychain.Keychain +import to.bitkit.repositories.PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID +import to.bitkit.repositories.PaykitAllowanceLocalState.JournalEntry +import to.bitkit.repositories.PaykitAllowanceLocalState.Stage +import to.bitkit.services.PaykitReceiverPaths +import to.bitkit.services.PaykitSdkService +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +@Suppress("LargeClass") +class PaykitAllowanceExecutorTest : BaseUnitTest() { + companion object { + private const val AUTOMATIC_ATTEMPT_ID = "attempt-1" + private const val MANUAL_ATTEMPT_ID = "manual-1" + private const val INVOICE = "lnbcrt10u1allowancetestinvoice" + private const val ONCHAIN_ADDRESS = "bcrt1qallowancetestaddress" + private val PAYMENT_HASH = "ab".repeat(32) + private val TXID = "c".repeat(64) + } + + private val fixtures = PaykitAllowanceFixtures + private val identity = fixtures.identityKey + private val sdk = mock() + private val payer = mock() + private val keychain = mock() + private var storedState: String? = null + private var now = fixtures.now + private val clock = object : Clock { + override fun now(): Instant = this@PaykitAllowanceExecutorTest.now + } + + private var accountingState: AllowanceAccountingState? = fixtures.accountingState() + private var candidates = listOf(candidate()) + private var automaticReservation: PaymentAttemptDecision? = null + private var manualReservation: PaymentAttemptDecision? = null + private var beginDecision: PaymentAttemptDecision? = null + private val evaluatedTrustedTimes = mutableListOf() + private val selections = mutableListOf() + private val acceptedChecks = mutableListOf() + private val reservedAssociationRevisions = mutableListOf() + private val recordedOutcomes = mutableListOf() + private val reconciliations = mutableListOf() + private val nodeStatuses = mutableMapOf() + private val events = mutableListOf() + private lateinit var sut: PaykitAllowanceExecutor + + @Before + fun setUp() = test { + stubLedger() + stubAttempts() + stubPayer() + val amount = fixtures.accountingAmount("0.00001") + sut = PaykitAllowanceExecutor( + ioDispatcher = testDispatcher, + paykitSdkService = sdk, + store = PaykitAllowanceStore(keychain), + payer = payer, + clock = clock, + accountingAmount = { _, _ -> amount }, + ) + } + + private suspend fun stubLedger() { + whenever(keychain.loadString(any())).thenAnswer { storedState } + whenever(keychain.upsertString(any(), any())).doSuspendableAnswer { storedState = it.getArgument(1) } + + whenever(sdk.allowanceAccountingState()).thenAnswer { accountingState } + whenever(sdk.reconcileAllowanceAccounting(any())).doSuspendableAnswer { + val reconciliation = it.getArgument(0) + reconciliations += reconciliation + AllowanceAccountingState( + revision = (reconciliation.expectedRevision ?: 0uL) + 1uL, + epoch = accountingState?.epoch ?: "epoch-1", + requiresReconciliation = false, + history = reconciliation.history, + ).also { reconciled -> accountingState = reconciled } + } + whenever(sdk.evaluateAllowanceCandidates(any(), any())).doSuspendableAnswer { + evaluatedTrustedTimes += it.getArgument(1) + candidates + } + whenever(sdk.acceptPaymentRequestAutomatically(any(), any(), any())).doSuspendableAnswer { + val selection = it.getArgument(1) + selections += selection + acceptedChecks += it.getArgument(2) + AllowanceAssociationRecord( + request = fixtures.accountingScope(it.getArgument(0).paymentRequestId), + revisions = listOf( + AllowanceAssociationRevision( + revision = 1uL, + allowanceId = selection.allowanceId, + effectiveFrom = null, + authorizationId = null, + authorizedAt = selection.trustedTime, + ), + ), + ) + } + whenever(sdk.processOutboundPrivateMessages(any(), any())) + .thenReturn(OutboundPrivateSendReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList())) + whenever(sdk.receivePrivateMessages(any(), any())) + .thenReturn(PrivateStreamIntakeReport(null, emptyList(), emptyList())) + } + + private suspend fun stubAttempts() { + val preparedAutomatic = PaymentAttemptDecision.Ready( + fixtures.attemptRecord(AUTOMATIC_ATTEMPT_ID, status = PaymentExecutionStatus.PREPARED), + ) + val submittedAutomatic = PaymentAttemptDecision.Ready( + fixtures.attemptRecord(AUTOMATIC_ATTEMPT_ID, status = PaymentExecutionStatus.SUBMITTED), + ) + val preparedManual = PaymentAttemptDecision.Ready( + fixtures.attemptRecord( + MANUAL_ATTEMPT_ID, + mode = PaymentExecutionMode.MANUAL, + allowanceId = null, + status = PaymentExecutionStatus.PREPARED, + ), + ) + val submittedManual = PaymentAttemptDecision.Ready( + fixtures.attemptRecord( + MANUAL_ATTEMPT_ID, + mode = PaymentExecutionMode.MANUAL, + allowanceId = null, + status = PaymentExecutionStatus.SUBMITTED, + ), + ) + val recordedAttempt = fixtures.attemptRecord(AUTOMATIC_ATTEMPT_ID, status = PaymentExecutionStatus.SUCCEEDED) + + whenever(sdk.reserveAutomaticPayment(any(), any(), any())).doSuspendableAnswer { + reservedAssociationRevisions += (it.arguments[1] as Long).toULong() + automaticReservation ?: preparedAutomatic + } + whenever(sdk.reserveManualPayment(any(), any())).doSuspendableAnswer { manualReservation ?: preparedManual } + whenever(sdk.beginPaymentExecution(any(), any())).doSuspendableAnswer { + beginDecision ?: if (it.getArgument(0) == MANUAL_ATTEMPT_ID) submittedManual else submittedAutomatic + } + whenever(sdk.recordPaymentOutcome(any())).doSuspendableAnswer { + recordedOutcomes += it.getArgument(0) + recordedAttempt + } + whenever(sdk.markPaymentManualOnly(any())).doSuspendableAnswer { + PaymentOccurrenceRecord( + key = PaymentOccurrenceKey(fixtures.accountingScope("manual-only"), billingPeriod = null), + disposition = PaymentDisposition.ManualOnly, + allowanceId = null, + associationRevision = null, + attempts = emptyList(), + ) + } + } + + private suspend fun stubPayer() { + whenever(payer.resolve(any(), any())).thenReturn(Result.success(lightningPayment())) + whenever(payer.consumePaymentList(any(), any())).thenReturn(Result.success(Unit)) + whenever(payer.prepareProof(any(), any(), anyOrNull())).thenReturn(Result.success(Unit)) + whenever(payer.associateLightningPayment(any(), any(), any())).thenReturn(Result.success(Unit)) + whenever(payer.markOnchainPaymentStarted(any(), any())).thenReturn(Result.success(Unit)) + whenever(payer.payLightning(any(), anyOrNull())).thenReturn(Result.success(PAYMENT_HASH)) + whenever(payer.payOnchain(any(), any())).thenReturn(Result.success(TXID)) + whenever(payer.failLightningPayment(any(), any())).thenReturn(true) + whenever(payer.lightningPaymentStatus(any())).thenAnswer { nodeStatuses[it.getArgument(0)] } + } + + // region Admission + + @Test + fun `uncovered request never reaches the SDK`() = test { + val request = fixtures.paymentRequest() + val uncovering = listOf( + emptyList(), + listOf(fixtures.allowance(role = PaykitAllowance.Role.ALLOWEE)), + listOf(fixtures.allowance(state = AllowanceLifecycleState.PROPOSED)), + listOf(fixtures.allowance(state = AllowanceLifecycleState.ENDED)), + listOf(fixtures.allowance(receiverPath = PaykitReceiverPaths.SERVER)), + listOf(fixtures.allowance(counterparty = fixtures.otherCounterpartyKey)), + ) + + for (allowances in uncovering) { + assertEquals(PaykitAllowanceAutoPayResult.NOT_COVERED, sut.autoPay(request, allowances, identity)) + } + verifyNoInteractions(sdk, payer, keychain) + } + + @Test + fun `covered lightning request runs admission in order and hands off to the node`() = test { + val request = fixtures.paymentRequest() + + val result = sut.autoPay(request, listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.STARTED, result) + val order = inOrder(sdk, payer) + order.verify(sdk).evaluateAllowanceCandidates(any(), any()) + order.verify(payer).resolve(eq(request), eq(listOf(fixtures.lightningIdentifier))) + order.verify(sdk).acceptPaymentRequestAutomatically(any(), any(), any()) + order.verify(sdk).reserveAutomaticPayment(any(), any(), any()) + order.verify(sdk).receivePrivateMessages(request.counterparty, request.counterpartyReceiverPath) + order.verify(sdk).beginPaymentExecution(eq(AUTOMATIC_ATTEMPT_ID), any()) + order.verify(payer).consumePaymentList(request.counterparty, lightningPayment().context) + order.verify(payer).prepareProof(request, fixtures.lightningIdentifier, WALLET_ALLOWANCE_ID) + order.verify(payer).associateLightningPayment(request, PAYMENT_HASH, fixtures.lightningIdentifier) + order.verify(payer).payLightning(eq(INVOICE), isNull()) + verify(sdk, never()).recordPaymentOutcome(any()) + assertEquals(listOf(PaykitAllowanceTime.format(fixtures.now)), evaluatedTrustedTimes) + assertEquals(listOf(WALLET_ALLOWANCE_ID), selections.map { it.allowanceId }) + assertEquals(listOf(fixtures.lightningIdentifier), acceptedChecks.map { it.paymentEndpointIdentifier }) + assertEquals(listOf(1uL), reservedAssociationRevisions) + + val entry = sut.localState(identity).journal.single() + assertEquals(AUTOMATIC_ATTEMPT_ID, entry.attemptId) + assertEquals(Stage.SENT, entry.stage) + assertTrue(entry.isAutomatic) + assertEquals(request.id, entry.requestId) + assertEquals(WALLET_ALLOWANCE_ID, entry.allowanceId) + assertEquals(1_000uL, entry.amountSats) + assertEquals(PAYMENT_HASH, entry.paymentHash) + assertEquals(fixtures.lightningIdentifier, entry.paymentEndpointIdentifier) + assertFalse(sut.isHandling(request.id)) + } + + @Test + fun `node settlement that arrives before the send call returns is kept`() = test { + sut.activate(identity) + whenever(payer.payLightning(any(), anyOrNull())).doSuspendableAnswer { + sut.lightningPaymentSettled(PAYMENT_HASH, succeeded = true) + Result.success(PAYMENT_HASH) + } + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.STARTED, result) + assertEquals(Stage.SUCCEEDED, sut.localState(identity).journal.single().stage) + assertEquals(listOf(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.SUCCEEDED)), recordedOutcomes) + } + + @Test + fun `amount-less invoice is paid exactly the requested amount`() = test { + whenever(payer.resolve(any(), any())) + .thenReturn(Result.success(lightningPayment().copy(lightningInvoiceHasAmount = false))) + + sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + verify(payer).payLightning(eq(INVOICE), eq(1_000uL)) + } + + @Test + fun `blocked candidate stays manual without acceptance`() = test { + candidates = listOf(candidate(blocked = AllowanceAccountingBlock.SharedRule("amount_outside_range"))) + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + verify(sdk).evaluateAllowanceCandidates(any(), any()) + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any()) + verify(sdk, never()).reserveAutomaticPayment(any(), any(), any()) + verifyNoInteractions(payer) + } + + @Test + fun `over the monthly cap stays manual and notifies once`() = test { + collectEvents() + accountingState = stateNearTheMonthlyCap() + val request = fixtures.paymentRequest() + + val first = sut.autoPay(request, listOf(fixtures.allowance()), identity) + val second = sut.autoPay(request, listOf(fixtures.allowance()), identity) + val limitReached: PaykitAllowanceEvent = PaykitAllowanceEvent.LimitReached(fixtures.counterpartyKey, 1_000uL) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, first) + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, second) + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any()) + verify(sdk, never()).reserveAutomaticPayment(any(), any(), any()) + verifyNoInteractions(payer) + assertEquals(listOf(limitReached), events) + } + + @Test + fun `blocked reservation marks the payment manual only`() = test { + collectEvents() + automaticReservation = PaymentAttemptDecision.Blocked( + AllowanceAccountingBlock.SharedRule("period_amount_limit"), + ) + val request = fixtures.paymentRequest() + + val result = sut.autoPay(request, listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + val limitReached: PaykitAllowanceEvent = PaykitAllowanceEvent.LimitReached(fixtures.counterpartyKey, 1_000uL) + val scope = PaymentRequestScope( + counterparty = request.counterparty, + counterpartyReceiverPath = request.counterpartyReceiverPath, + paymentRequestId = request.paymentRequestId, + ) + verify(sdk).markPaymentManualOnly(PaymentOccurrence(scope, billingPeriod = null)) + verify(sdk, never()).beginPaymentExecution(any(), any()) + verify(payer, never()).payLightning(any(), anyOrNull()) + assertEquals(emptyList(), sut.localState(identity).journal) + assertEquals(listOf(limitReached), events) + } + + @Test + fun `blocked begin records a failed outcome`() = test { + beginDecision = PaymentAttemptDecision.Blocked(AllowanceAccountingBlock.ManualOnly) + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + 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()).prepareProof(any(), any(), anyOrNull()) + verify(payer, never()).payLightning(any(), anyOrNull()) + } + + @Test + fun `failed proof preparation releases the attempt before any payment`() = test { + whenever(payer.prepareProof(any(), any(), anyOrNull())) + .thenReturn(Result.failure(PaykitPaymentRequestError.RequestUnavailable)) + val request = fixtures.paymentRequest() + + val result = sut.autoPay(request, listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + verify(payer).cancelProofPreparation(request) + assertEquals(listOf(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.FAILED)), recordedOutcomes) + verify(payer, never()).payLightning(any(), anyOrNull()) + } + + @Test + fun `lightning send failure releases only a payment that never left`() = test { + whenever(payer.payLightning(any(), anyOrNull())).thenReturn(Result.failure(TestAllowanceError("send"))) + whenever(payer.failLightningPayment(any(), any())).thenReturn(true, false) + + val first = sut.autoPay(fixtures.paymentRequest(id = "request-1"), listOf(fixtures.allowance()), identity) + val second = sut.autoPay(fixtures.paymentRequest(id = "request-2"), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, first) + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, second) + assertEquals( + listOf(PaymentOutcome.FAILED, PaymentOutcome.UNKNOWN), + recordedOutcomes.map { it.outcome }, + ) + assertEquals(Stage.UNKNOWN, sut.localState(identity).journal.single().stage) + } + + @Test + fun `covered on-chain request completes and reports the payment`() = test { + collectEvents() + whenever(payer.resolve(any(), any())).thenReturn(Result.success(onchainPayment())) + val request = fixtures.paymentRequest() + + val result = sut.autoPay(request, listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.COMPLETED, result) + val order = inOrder(payer, sdk) + order.verify(payer).prepareProof(request, fixtures.onchainIdentifier, WALLET_ALLOWANCE_ID) + order.verify(payer).markOnchainPaymentStarted(request, ONCHAIN_ADDRESS) + order.verify(payer).payOnchain(eq(ONCHAIN_ADDRESS), any()) + order.verify(payer).completeOnchainPayment(request, TXID, fixtures.onchainIdentifier) + order.verify(sdk).recordPaymentOutcome(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.SUCCEEDED)) + val entry = sut.localState(identity).journal.single() + assertEquals(Stage.SUCCEEDED, entry.stage) + assertEquals(TXID, entry.transactionId) + assertEquals(ONCHAIN_ADDRESS, entry.onchainAddress) + assertNull(entry.paymentHash) + assertTrue(PaykitAllowanceEvent.PaidAutomatically(fixtures.counterpartyKey, 1_000uL, TXID) in events) + assertEquals(listOf(AUTOMATIC_ATTEMPT_ID), sut.succeededAutomaticPayments(identity).map { it.attemptId }) + } + + @Test + fun `on-chain send failure keeps the attempt reserved unless nothing was broadcast`() = test { + whenever(payer.resolve(any(), any())).thenReturn(Result.success(onchainPayment())) + whenever(payer.payOnchain(any(), any())).thenReturn( + Result.failure(PaykitAllowanceOnchainSendError(true, TestAllowanceError("funds"))), + Result.failure(PaykitAllowanceOnchainSendError(false, TestAllowanceError("timeout"))), + ) + val definite = fixtures.paymentRequest(id = "request-1") + val uncertain = fixtures.paymentRequest(id = "request-2") + + sut.autoPay(definite, listOf(fixtures.allowance()), identity) + sut.autoPay(uncertain, listOf(fixtures.allowance()), identity) + + assertEquals(listOf(PaymentOutcome.FAILED, PaymentOutcome.UNKNOWN), recordedOutcomes.map { it.outcome }) + verify(payer).failOnchainPayment(definite) + verify(payer, never()).failOnchainPayment(uncertain) + verify(payer, never()).completeOnchainPayment(any(), any(), any()) + } + + // endregion + + // region Settlement and recovery + + @Test + fun `lightning settlement records success for the journaled attempt`() = test { + collectEvents() + val request = fixtures.paymentRequest() + seedJournal(journalEntry("attempt-1", request, Stage.SENT, paymentHash = PAYMENT_HASH)) + sut.activate(identity) + + sut.lightningPaymentSettled("f".repeat(64), succeeded = true) + assertEquals(emptyList(), recordedOutcomes) + + sut.lightningPaymentSettled(PAYMENT_HASH.uppercase(), succeeded = true) + sut.lightningPaymentSettled(PAYMENT_HASH, succeeded = true) + + assertEquals(listOf(PaymentOutcomeReport("attempt-1", PaymentOutcome.SUCCEEDED)), recordedOutcomes) + assertEquals(listOf(Stage.SUCCEEDED), sut.localState(identity).journal.map { it.stage }) + assertEquals(listOf("attempt-1"), sut.succeededAutomaticPayments(identity).map { it.attemptId }) + assertEquals( + listOf(PaykitAllowanceEvent.PaidAutomatically(fixtures.counterpartyKey, 1_000uL, PAYMENT_HASH)), + events.filterIsInstance(), + ) + verify(payer, never()).payLightning(any(), anyOrNull()) + verify(payer, never()).payOnchain(any(), any()) + } + + @Test + fun `open lightning attempts settle from the node's payment status`() = test { + val paidHash = "1".repeat(64) + val pendingHash = "2".repeat(64) + val request = fixtures.paymentRequest() + seedJournal( + journalEntry("paid", request, Stage.UNKNOWN, paymentHash = paidHash), + journalEntry("pending", request, Stage.SENT, paymentHash = pendingHash), + ) + nodeStatuses[paidHash] = PaymentStatus.SUCCEEDED + nodeStatuses[pendingHash] = PaymentStatus.PENDING + sut.activate(identity) + + sut.settleOpenLightningAttempts() + + assertEquals(listOf(PaymentOutcomeReport("paid", PaymentOutcome.SUCCEEDED)), recordedOutcomes) + } + + @Test + fun `recovery resolves open attempts without paying again`() = test { + val request = fixtures.paymentRequest() + val paidHash = "1".repeat(64) + val pendingHash = "2".repeat(64) + accountingState = fixtures.accountingState( + revision = 4uL, + occurrences = listOf( + occurrence("prepared", PaymentExecutionStatus.PREPARED), + occurrence("old-epoch", PaymentExecutionStatus.PREPARED, epoch = "epoch-0"), + occurrence("never-sent", PaymentExecutionStatus.SUBMITTED), + occurrence("sent-paid", PaymentExecutionStatus.SUBMITTED), + occurrence("sent-pending", PaymentExecutionStatus.SUBMITTED), + occurrence("done", PaymentExecutionStatus.SUCCEEDED), + ), + ) + seedJournal( + journalEntry("prepared", request, Stage.PREPARED), + journalEntry("never-sent", request, Stage.SUBMITTED), + journalEntry("sent-paid", request, Stage.SENT, paymentHash = paidHash), + journalEntry("sent-pending", request, Stage.SENT, paymentHash = pendingHash), + ) + nodeStatuses[paidHash] = PaymentStatus.SUCCEEDED + nodeStatuses[pendingHash] = PaymentStatus.PENDING + + sut.recover(identity) + + val expected = mapOf( + "prepared" to PaymentOutcome.FAILED, + "never-sent" to PaymentOutcome.FAILED, + "sent-paid" to PaymentOutcome.SUCCEEDED, + "sent-pending" to PaymentOutcome.UNKNOWN, + ) + assertEquals(expected, recordedOutcomes.associate { it.attemptId to it.outcome }) + assertEquals(4, recordedOutcomes.size) + assertEquals( + mapOf( + "prepared" to Stage.FAILED, + "never-sent" to Stage.FAILED, + "sent-paid" to Stage.SUCCEEDED, + "sent-pending" to Stage.UNKNOWN, + ), + sut.localState(identity).journal.associate { it.attemptId to it.stage }, + ) + verify(payer, never()).payLightning(any(), anyOrNull()) + verify(payer, never()).payOnchain(any(), any()) + verify(payer, never()).resolve(any(), any()) + assertEquals(emptyList(), reconciliations) + } + + @Test + fun `recovery leaves a wallet without a ledger untouched`() = test { + accountingState = null + + sut.recover(identity) + + verify(sdk).allowanceAccountingState() + verify(sdk, never()).reconcileAllowanceAccounting(any()) + verify(sdk, never()).recordPaymentOutcome(any()) + } + + @Test + fun `recovery leaves a manual payment in progress alone`() = test { + sut.activate(identity) + val attemptId = sut.beginManualPayment(fixtures.paymentRequest(), fixtures.lightningIdentifier).getOrThrow() + accountingState = fixtures.accountingState( + occurrences = listOf(occurrence(MANUAL_ATTEMPT_ID, PaymentExecutionStatus.SUBMITTED)), + ) + + sut.recover(identity) + assertEquals(emptyList(), recordedOutcomes) + + sut.finishManualPayment(checkNotNull(attemptId), PaymentOutcome.SUCCEEDED) + assertEquals(listOf(PaymentOutcomeReport(MANUAL_ATTEMPT_ID, PaymentOutcome.SUCCEEDED)), recordedOutcomes) + } + + // endregion + + // region Manual payments + + @Test + fun `manual payment fails when the request is already recorded`() = test { + sut.activate(identity) + manualReservation = PaymentAttemptDecision.Blocked(AllowanceAccountingBlock.PaymentAlreadyRecorded) + + val result = sut.beginManualPayment(fixtures.paymentRequest(), fixtures.lightningIdentifier) + + assertIs(result.exceptionOrNull()) + verify(sdk, never()).beginPaymentExecution(any(), any()) + } + + @Test + fun `manual payment is journaled and submitted when ready`() = test { + sut.activate(identity) + val request = fixtures.paymentRequest() + + val attemptId = sut.beginManualPayment(request, fixtures.lightningIdentifier).getOrThrow() + + assertEquals(MANUAL_ATTEMPT_ID, attemptId) + val entry = sut.localState(identity).journal.single() + assertEquals(Stage.SUBMITTED, entry.stage) + assertFalse(entry.isAutomatic) + assertNull(entry.allowanceId) + + sut.manualLightningPaymentSent(MANUAL_ATTEMPT_ID, PAYMENT_HASH.uppercase()) + assertEquals(PAYMENT_HASH, sut.localState(identity).journal.single().paymentHash) + assertEquals(Stage.SENT, sut.localState(identity).journal.single().stage) + + manualReservation = PaymentAttemptDecision.Blocked(AllowanceAccountingBlock.ManualOnly) + assertNull(sut.beginManualPayment(request, fixtures.lightningIdentifier).getOrThrow()) + } + + @Test + fun `manual lightning attempt settled by the node is recorded once and never counts as automatic`() = test { + collectEvents() + sut.activate(identity) + val attemptId = checkNotNull( + sut.beginManualPayment(fixtures.paymentRequest(), fixtures.lightningIdentifier).getOrThrow(), + ) + sut.manualLightningPaymentSent(attemptId, PAYMENT_HASH) + + sut.lightningPaymentSettled(PAYMENT_HASH, succeeded = true) + sut.finishManualPayment(attemptId, PaymentOutcome.SUCCEEDED) + + assertEquals(listOf(PaymentOutcomeReport(MANUAL_ATTEMPT_ID, PaymentOutcome.SUCCEEDED)), recordedOutcomes) + assertEquals(emptyList(), events.filterIsInstance()) + assertEquals(emptyList(), sut.succeededAutomaticPayments(identity)) + } + + @Test + fun `manual payment is not reported without an identity or for an outgoing request`() = test { + assertNull(sut.beginManualPayment(fixtures.paymentRequest(), fixtures.lightningIdentifier).getOrThrow()) + + sut.activate(identity) + val outgoing = fixtures.paymentRequest().copy(direction = PaykitPaymentRequestDirection.Outgoing) + assertNull(sut.beginManualPayment(outgoing, fixtures.lightningIdentifier).getOrThrow()) + verifyNoInteractions(sdk) + } + + // endregion + + // region Trusted time and reconciliation + + @Test + fun `trusted time never moves backwards`() = test { + val start = fixtures.now + + val first = sut.trustedTime(identity) + now = start - 1.hours + val afterClockMovedBack = sut.trustedTime(identity) + now = start + 1.minutes + val afterClockMovedForward = sut.trustedTime(identity) + + assertEquals(PaykitAllowanceTime.format(start), first) + assertEquals(PaykitAllowanceTime.format(start), afterClockMovedBack) + assertEquals(PaykitAllowanceTime.format(start + 1.minutes), afterClockMovedForward) + assertEquals((start + 1.minutes).toEpochMilliseconds(), sut.localState(identity).lastTrustedTimeMillis) + } + + @Test + fun `missing ledger is reconciled with an empty history`() = test { + accountingState = null + + val reconciled = sut.ensureReconciled(identity) + sut.ensureReconciled(identity) + + val reconciliation = reconciliations.single() + assertNull(reconciliation.expectedRevision) + assertTrue(reconciliation.history.associations.isEmpty()) + assertTrue(reconciliation.history.occurrences.isEmpty()) + assertTrue(reconciliation.history.watermarks.isEmpty()) + assertEquals(emptyList(), reconciliation.outcomes) + assertEquals(PaykitAllowanceTime.format(fixtures.now), reconciliation.trustedTime) + assertFalse(reconciled.requiresReconciliation) + } + + @Test + fun `ledger that requires reconciliation is reconciled at its revision`() = test { + val request = fixtures.paymentRequest() + val paidHash = "3".repeat(64) + accountingState = fixtures.accountingState( + revision = 7uL, + requiresReconciliation = true, + occurrences = listOf( + occurrence("prepared", PaymentExecutionStatus.PREPARED), + occurrence("sent-paid", PaymentExecutionStatus.SUBMITTED), + ), + ) + seedJournal(journalEntry("sent-paid", request, Stage.SENT, paymentHash = paidHash)) + nodeStatuses[paidHash] = PaymentStatus.SUCCEEDED + + sut.ensureReconciled(identity) + + val reconciliation = reconciliations.single() + assertEquals(7uL, reconciliation.expectedRevision) + assertEquals( + listOf("prepared", "sent-paid"), + reconciliation.history.occurrences.flatMap { it.attempts }.map { it.attemptId }, + ) + assertEquals( + listOf( + PaymentOutcomeReport("prepared", PaymentOutcome.FAILED), + PaymentOutcomeReport("sent-paid", PaymentOutcome.SUCCEEDED), + ), + reconciliation.outcomes, + ) + verify(payer, never()).payLightning(any(), anyOrNull()) + verify(payer, never()).payOnchain(any(), any()) + } + + @Test + fun `admission uses the injected clock for the monthly window`() = test { + accountingState = stateNearTheMonthlyCap() + now = Instant.parse("2026-10-02T12:00:00Z") + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.STARTED, result, "September's attempts must not count in October") + assertEquals(listOf(PaykitAllowanceTime.format(now)), evaluatedTrustedTimes) + } + + // endregion + + // region Helpers + + private fun TestScope.collectEvents() { + backgroundScope.launch { sut.events.collect { events += it } } + } + + private fun candidate(blocked: AllowanceAccountingBlock? = null) = AllowanceCandidate( + allowanceId = WALLET_ALLOWANCE_ID, + eligiblePaymentEndpointIdentifiers = listOf(PaykitAllowanceFixtures.lightningIdentifier), + blocked = blocked, + ) + + private fun lightningPayment() = PrivatePaykitAllowancePayment( + endpoint = Endpoint( + methodId = MethodId.Bolt11, + value = INVOICE, + rawPayload = """{"value":"$INVOICE"}""", + ), + context = PrivatePaykitPaymentContext(PaykitReceiverPaths.WALLET, 3uL), + lightningPaymentHash = PAYMENT_HASH, + lightningInvoiceHasAmount = true, + ) + + private fun onchainPayment() = PrivatePaykitAllowancePayment( + endpoint = Endpoint( + methodId = MethodId.P2wpkh, + value = ONCHAIN_ADDRESS, + rawPayload = """{"value":"$ONCHAIN_ADDRESS"}""", + ), + context = PrivatePaykitPaymentContext(PaykitReceiverPaths.WALLET, 3uL), + lightningPaymentHash = null, + lightningInvoiceHasAmount = false, + ) + + /** Three succeeded automatic payments this month total 49,500 sats of the 50,000 sat cap. */ + private fun stateNearTheMonthlyCap() = fixtures.accountingState( + occurrences = listOf( + fixtures.occurrence( + "paid-1", + listOf(paidAttempt("paid-1", "0.0002", "2026-09-05T10:00:00Z")), + ), + fixtures.occurrence( + "paid-2", + listOf(paidAttempt("paid-2", "0.0002", "2026-09-12T10:00:00Z")), + ), + fixtures.occurrence( + "paid-3", + listOf(paidAttempt("paid-3", "0.000095", "2026-09-20T10:00:00Z")), + ), + ), + ) + + private fun paidAttempt(id: String, amount: String, admittedAt: String) = fixtures.attemptRecord( + id = id, + amount = amount, + admittedAt = admittedAt, + status = PaymentExecutionStatus.SUCCEEDED, + ) + + private fun occurrence( + attemptId: String, + status: PaymentExecutionStatus, + epoch: String = "epoch-1", + ) = fixtures.occurrence( + requestId = "req-$attemptId", + attempts = listOf(fixtures.attemptRecord(id = attemptId, status = status, epoch = epoch)), + ) + + private fun journalEntry( + attemptId: String, + request: PaykitPaymentRequest, + stage: Stage, + paymentHash: String? = null, + ) = JournalEntry( + attemptId = attemptId, + isAutomatic = true, + requestId = request.id, + allowanceId = WALLET_ALLOWANCE_ID, + amountSats = request.amountSats, + paymentEndpointIdentifier = fixtures.lightningIdentifier, + paymentHash = paymentHash, + stage = stage, + createdAtMillis = fixtures.now.toEpochMilliseconds(), + ) + + private suspend fun seedJournal(vararg entries: JournalEntry) { + sut.updateLocalState(identity) { it.copy(journal = entries.toList()) } + } + + // endregion +} + +private class TestAllowanceError(message: String) : AppError(message) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt new file mode 100644 index 0000000000..d00148cd36 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt @@ -0,0 +1,500 @@ +package to.bitkit.repositories + +import com.synonym.paykit.AllowanceHistoryStatus +import com.synonym.paykit.AllowanceLifecycleState +import com.synonym.paykit.AllowanceLocalRole +import com.synonym.paykit.AllowanceRecord +import com.synonym.paykit.LinkedPeerRecord +import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.OutboundPrivateSendReport +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Before +import org.junit.Test +import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.PaymentFailureReason +import org.mockito.kotlin.any +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.models.NodeLifecycleState +import to.bitkit.repositories.PaykitAllowanceFixtures.SERVER_ALLOWANCE_ID +import to.bitkit.repositories.PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID +import to.bitkit.repositories.PaykitAllowanceLocalState.Stage +import to.bitkit.services.PaykitReceiverPaths +import to.bitkit.services.PaykitSdkService +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +class PaykitAllowanceRepoTest : BaseUnitTest() { + companion object { + private val PAYMENT_HASH = "ab".repeat(32) + private val TXID = "c".repeat(64) + } + + private val fixtures = PaykitAllowanceFixtures + private val identity = fixtures.identityKey + private val sdk = mock() + private val executor = mock() + private val lightningRepo = mock() + private val activityRepo = mock() + private val executorEvents = MutableSharedFlow(extraBufferCapacity = 8) + private val nodeEvents = MutableSharedFlow(extraBufferCapacity = 8) + private val lightningState = MutableStateFlow(LightningState()) + private val terms = fixtures.terms() + private val clock = object : Clock { + override fun now(): Instant = PaykitAllowanceFixtures.now + } + private val proposedTerms = mutableListOf>>() + private var localState = PaykitAllowanceLocalState() + private var records = listOf() + private lateinit var sut: PaykitAllowanceRepo + + @Before + fun setUp() = test { + whenever(executor.events).thenReturn(executorEvents) + whenever(executor.localState(any())).thenAnswer { localState } + whenever(executor.updateLocalState(any(), any())).doSuspendableAnswer { + 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(lightningRepo.nodeEvents).thenReturn(nodeEvents) + whenever(lightningRepo.lightningState).thenReturn(lightningState) + whenever(activityRepo.setContact(any(), any(), any(), any())).thenReturn(Result.success(Unit)) + whenever(sdk.listAllowances(any())).thenAnswer { records } + whenever(sdk.linkedPeers()).thenReturn(emptyList()) + whenever(sdk.processOutboundPrivateMessages(any(), any())) + .thenReturn(OutboundPrivateSendReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList())) + + sut = PaykitAllowanceRepo( + ioDispatcher = testDispatcher, + paykitSdkService = sdk, + executor = executor, + lightningRepo = lightningRepo, + activityRepo = activityRepo, + clock = clock, + allowanceTerms = { _, monthAnchor, endpoints -> + proposedTerms += monthAnchor to endpoints + terms + }, + ) + } + + // region Entries + + @Test + fun `entries group one grant across links with the wallet link as primary`() = test { + records = listOf( + fixtures.record(allowanceId = SERVER_ALLOWANCE_ID, receiverPath = PaykitReceiverPaths.SERVER), + fixtures.record(allowanceId = WALLET_ALLOWANCE_ID), + fixtures.record(allowanceId = "allowance-other", counterparty = fixtures.otherCounterpartyKey), + fixtures.record(allowanceId = "allowance-invalid", historyStatus = AllowanceHistoryStatus.INVALID), + ) + localState = PaykitAllowanceLocalState(groups = listOf(group(WALLET_ALLOWANCE_ID, SERVER_ALLOWANCE_ID))) + + sut.activate(identity) + + val entries = sut.entries.value + assertEquals(listOf("group-1", "allowance-other"), entries.map { it.id }) + val grouped = entries.first() + assertEquals(listOf(SERVER_ALLOWANCE_ID, WALLET_ALLOWANCE_ID), grouped.allowances.map { it.allowanceId }) + assertEquals(WALLET_ALLOWANCE_ID, grouped.primary.allowanceId) + assertEquals(fixtures.limits, grouped.limits) + assertEquals(fixtures.counterpartyKey, grouped.counterparty) + assertEquals(PaykitAllowance.Role.ALLOWER, grouped.role) + assertEquals(5_000uL, grouped.perPaymentMaxSats) + assertEquals(50_000uL, grouped.monthlyLimitSats) + assertEquals(PaykitAllowance.Status.ACTIVE, grouped.status(fixtures.now)) + assertNull(entries.last().limits) + assertEquals(WALLET_ALLOWANCE_ID, sut.entry("group-1")?.primary?.allowanceId) + } + + @Test + fun `activation recovers once per identity and deactivation clears entries`() = test { + records = listOf(fixtures.record()) + + sut.activate(identity) + sut.activate(identity) + + verify(executor, times(1)).recover(identity) + verify(executor, times(2)).activate(identity) + assertEquals(1, sut.entries.value.size) + + sut.deactivate() + + verify(executor).activate(null) + assertEquals(emptyList(), sut.entries.value) + assertTrue(sut.refresh().isSuccess) + verify(sdk, times(2)).listAllowances(any()) + } + + @Test + fun `auto-paid sats and requests come from succeeded automatic payments`() = test { + records = listOf( + fixtures.record(allowanceId = SERVER_ALLOWANCE_ID, receiverPath = PaykitReceiverPaths.SERVER), + fixtures.record(allowanceId = WALLET_ALLOWANCE_ID), + ) + val paid = fixtures.paymentRequest(id = "paid") + val failed = fixtures.paymentRequest(id = "failed") + val serverPaid = fixtures.paymentRequest(id = "server") + localState = PaykitAllowanceLocalState( + groups = listOf(group(WALLET_ALLOWANCE_ID, SERVER_ALLOWANCE_ID)), + journal = listOf( + journalEntry("a", paid, WALLET_ALLOWANCE_ID, 1_000uL, Stage.SUCCEEDED), + journalEntry("b", serverPaid, SERVER_ALLOWANCE_ID, 2_000uL, Stage.SUCCEEDED), + journalEntry("c", failed, WALLET_ALLOWANCE_ID, 5_000uL, Stage.FAILED), + journalEntry("d", fixtures.paymentRequest(id = "manual"), null, 7_000uL, Stage.SUCCEEDED, false), + ), + ) + + 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)) + } + + // endregion + + // region Coverage + + @Test + fun `coverage needs an active allower allowance on the request's exact link`() = test { + records = listOf(fixtures.record(terms = fixtures.terms(expiresAt = (fixtures.now + 30.days).toString()))) + + sut.activate(identity) + + assertTrue(sut.coversRequest(fixtures.paymentRequest())) + assertFalse(sut.coversRequest(fixtures.paymentRequest(counterparty = fixtures.otherCounterpartyKey))) + assertFalse(sut.coversRequest(fixtures.paymentRequest(receiverPath = PaykitReceiverPaths.SERVER))) + + records = listOf( + fixtures.record(terms = fixtures.terms(expiresAt = "2026-09-20T00:00:00Z")), + fixtures.record(allowanceId = "allowee", localRole = AllowanceLocalRole.ALLOWEE, proposedByMe = false), + fixtures.record(allowanceId = "proposed", state = AllowanceLifecycleState.PROPOSED), + ) + sut.refresh() + + assertFalse(sut.coversRequest(fixtures.paymentRequest())) + } + + @Test + fun `requests created before the allowance was accepted stay manual`() = test { + records = listOf(fixtures.record(lastEventAt = "2026-09-24T11:30:00Z")) + + sut.activate(identity) + + assertFalse(sut.coversRequest(fixtures.paymentRequest(createdAt = "2026-09-24T11:00:00Z"))) + assertTrue(sut.coversRequest(fixtures.paymentRequest(createdAt = "2026-09-24T11:29:40Z")), "Within tolerance") + assertTrue(sut.coversRequest(fixtures.paymentRequest(createdAt = "2026-09-24T11:45:00Z"))) + } + + // endregion + + // region Lifecycle + + @Test + fun `propose sends the same terms on every supported linked path and records one entry`() = test { + whenever(sdk.linkedPeers()).thenReturn( + listOf( + linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER), + linkedPeer(fixtures.counterpartyKey, "a/other"), + linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET), + linkedPeer(fixtures.otherCounterpartyKey, PaykitReceiverPaths.WALLET), + linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET, LinkedPeerState.LINKING), + ), + ) + whenever(sdk.proposeAllowance(any(), any(), any(), any())).doSuspendableAnswer { + val receiverPath = it.getArgument(1) + val isWallet = receiverPath == PaykitReceiverPaths.WALLET + val allowanceId = if (isWallet) WALLET_ALLOWANCE_ID else SERVER_ALLOWANCE_ID + fixtures.record( + allowanceId = allowanceId, + receiverPath = receiverPath, + state = AllowanceLifecycleState.PROPOSED, + terms = terms, + ).also { record -> records = records + record } + } + sut.activate(identity) + + val result = sut.propose(fixtures.counterpartyKey, fixtures.limits) + + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + val allower = AllowanceLocalRole.ALLOWER + verify(sdk).proposeAllowance(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET, allower, terms) + verify(sdk).proposeAllowance(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER, allower, terms) + verify(sdk, never()).proposeAllowance(any(), eq("a/other"), any(), any()) + verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET) + verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER) + assertEquals( + listOf(fixtures.septemberAnchor to PaykitAllowanceRepo.allowedPaymentEndpointIdentifiers()), + proposedTerms, + ) + val group = localState.groups.single() + assertEquals(listOf(WALLET_ALLOWANCE_ID, SERVER_ALLOWANCE_ID), group.allowanceIds) + assertEquals(fixtures.limits, group.limits) + assertEquals(fixtures.counterpartyKey, group.counterparty) + val entry = sut.entries.value.single() + assertEquals(group.id, entry.id) + assertEquals(fixtures.limits, entry.limits) + assertEquals(PaykitAllowance.Status.AWAITING_ANSWER, entry.status(fixtures.now)) + } + + @Test + fun `propose keeps the wallet proposal when the server link fails`() = test { + whenever(sdk.linkedPeers()).thenReturn( + listOf( + linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET), + linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER), + ), + ) + whenever(sdk.proposeAllowance(any(), any(), any(), any())).doSuspendableAnswer { + if (it.getArgument(1) == PaykitReceiverPaths.SERVER) throw TestRepoError("server link") + fixtures.record(state = AllowanceLifecycleState.PROPOSED, terms = terms) + } + sut.activate(identity) + + assertTrue(sut.propose(fixtures.counterpartyKey, fixtures.limits).isSuccess) + + assertEquals(listOf(WALLET_ALLOWANCE_ID), localState.groups.single().allowanceIds) + } + + @Test + fun `propose fails when the contact has no linked receiver`() = test { + whenever(sdk.linkedPeers()) + .thenReturn( + listOf(linkedPeer(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET, LinkedPeerState.LINKING)), + ) + sut.activate(identity) + + val result = sut.propose(fixtures.counterpartyKey, fixtures.limits) + + assertIs(result.exceptionOrNull()) + verify(sdk, never()).proposeAllowance(any(), any(), any(), any()) + assertTrue(localState.groups.isEmpty()) + } + + @Test + fun `received proposal is presented once and accepting answers it`() = test { + val proposalRecord = receivedProposal() + records = listOf(proposalRecord) + whenever(sdk.acceptAllowance(any(), any(), any())).thenReturn(proposalRecord) + sut.activate(identity) + + val proposal = checkNotNull(sut.proposalForPresentation()) + assertEquals(WALLET_ALLOWANCE_ID, proposal.id) + sut.markProposalPresented(proposal.id) + assertNull(sut.proposalForPresentation()) + assertEquals(setOf(WALLET_ALLOWANCE_ID), localState.presentedProposalIds) + + assertTrue(sut.accept(proposal.id).isSuccess) + + verify(sdk).acceptAllowance(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET, WALLET_ALLOWANCE_ID) + verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET) + } + + @Test + fun `answering fails when nothing in the entry awaits an answer`() = test { + records = listOf(fixtures.record()) + sut.activate(identity) + + assertIs(sut.reject(WALLET_ALLOWANCE_ID).exceptionOrNull()) + assertIs(sut.accept("missing").exceptionOrNull()) + verify(sdk, never()).rejectAllowance(any(), any(), any()) + } + + @Test + fun `ending an entry ends every endable allowance`() = test { + records = listOf( + fixtures.record(allowanceId = SERVER_ALLOWANCE_ID, receiverPath = PaykitReceiverPaths.SERVER), + fixtures.record(allowanceId = WALLET_ALLOWANCE_ID), + ) + localState = PaykitAllowanceLocalState(groups = listOf(group(WALLET_ALLOWANCE_ID, SERVER_ALLOWANCE_ID))) + whenever(sdk.endAllowance(any(), any(), any())).thenReturn(records.last()) + sut.activate(identity) + + assertTrue(sut.end("group-1").isSuccess) + + verify(sdk).endAllowance(fixtures.counterpartyKey, PaykitReceiverPaths.WALLET, WALLET_ALLOWANCE_ID) + verify(sdk).endAllowance(fixtures.counterpartyKey, PaykitReceiverPaths.SERVER, SERVER_ALLOWANCE_ID) + } + + // endregion + + // region Automatic payments + + @Test + fun `covered request stays off the Send sheet until it is found manual`() = test { + records = listOf(fixtures.record()) + whenever(executor.autoPay(any(), any(), any())).thenReturn(PaykitAllowanceAutoPayResult.MANUAL) + sut.activate(identity) + val request = fixtures.paymentRequest() + + assertTrue(sut.isAutomaticallyHandling(request)) + assertFalse(sut.isAutomaticallyHandling(fixtures.paymentRequest(counterparty = fixtures.otherCounterpartyKey))) + + assertFalse(sut.processIncomingRequests(listOf(request))) + assertFalse(sut.isAutomaticallyHandling(request)) + assertFalse(sut.processIncomingRequests(listOf(request))) + verify(executor, times(1)).autoPay(any(), any(), any()) + + records = listOf(fixtures.record(), fixtures.record(allowanceId = SERVER_ALLOWANCE_ID)) + sut.refresh() + sut.processIncomingRequests(listOf(request)) + verify(executor, times(2)).autoPay(any(), any(), any()) + } + + @Test + fun `request being paid stays off the Send sheet`() = test { + sut.activate(identity) + val request = fixtures.paymentRequest() + whenever(executor.isHandling(request.id)).thenReturn(true) + + assertTrue(sut.isAutomaticallyHandling(request)) + } + + @Test + fun `started payment is reported and refreshes the allowances`() = test { + records = listOf(fixtures.record()) + sut.activate(identity) + val uncovered = fixtures.paymentRequest(id = "other", counterparty = fixtures.otherCounterpartyKey) + + val handled = sut.processIncomingRequests(listOf(fixtures.paymentRequest(), uncovered)) + + assertTrue(handled) + verify(executor).autoPay(eq(fixtures.paymentRequest()), eq(sut.entries.value.single().allowances), eq(identity)) + verify(executor, never()).autoPay(eq(uncovered), any(), any()) + verify(sdk, times(2)).listAllowances(any()) + } + + @Test + fun `nothing is processed without an identity`() = test { + assertFalse(sut.processIncomingRequests(listOf(fixtures.paymentRequest()))) + verify(executor, never()).autoPay(any(), any(), any()) + } + + // endregion + + // region Events + + @Test + fun `node payment events settle allowance attempts`() = test { + nodeEvents.emit( + Event.PaymentSuccessful( + paymentId = "payment-id", + paymentHash = PAYMENT_HASH, + paymentPreimage = "00".repeat(32), + feePaidMsat = 10uL, + ), + ) + nodeEvents.emit( + Event.PaymentFailed( + paymentId = PAYMENT_HASH, + paymentHash = null, + reason = PaymentFailureReason.RETRIES_EXHAUSTED, + ), + ) + + verify(executor).lightningPaymentSettled(PAYMENT_HASH, true) + verify(executor).lightningPaymentSettled(PAYMENT_HASH, false) + } + + @Test + 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(), WALLET_ALLOWANCE_ID, 1_000uL, Stage.SUCCEEDED), + ), + ) + + executorEvents.emit(PaykitAllowanceEvent.PaidAutomatically(fixtures.counterpartyKey, 1_000uL, TXID)) + + verify(activityRepo).setContact(eq(fixtures.counterpartyKey), eq(TXID), any(), any()) + assertEquals(mapOf(WALLET_ALLOWANCE_ID to 1_000uL), sut.autoPaidSats.value) + } + + @Test + fun `running node settles open lightning attempts`() = test { + lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } + + verify(executor).settleOpenLightningAttempts() + } + + // endregion + + // region Helpers + + private fun group(vararg allowanceIds: String) = PaykitAllowanceLocalState.Group( + id = "group-1", + counterparty = fixtures.counterpartyKey, + limits = fixtures.limits, + allowanceIds = allowanceIds.toList(), + createdAtMillis = fixtures.now.toEpochMilliseconds(), + ) + + private fun receivedProposal() = fixtures.record( + localRole = AllowanceLocalRole.ALLOWEE, + state = AllowanceLifecycleState.PROPOSED, + proposedByMe = false, + ) + + @Suppress("LongParameterList") + private fun journalEntry( + attemptId: String, + request: PaykitPaymentRequest, + allowanceId: String?, + amountSats: ULong, + stage: Stage, + isAutomatic: Boolean = true, + ) = PaykitAllowanceLocalState.JournalEntry( + attemptId = attemptId, + isAutomatic = isAutomatic, + requestId = request.id, + allowanceId = allowanceId, + amountSats = amountSats, + paymentEndpointIdentifier = fixtures.lightningIdentifier, + stage = stage, + createdAtMillis = fixtures.now.toEpochMilliseconds(), + ) + + private fun linkedPeer( + publicKey: String, + receiverPath: String, + state: LinkedPeerState = LinkedPeerState.LINKED, + ) = LinkedPeerRecord( + counterparty = publicKey, + counterpartyReceiverPath = receiverPath, + state = state, + lastSyncAt = null, + lastPrivateReceiveAt = null, + failureCount = 0u, + localRecoveryAttemptId = null, + localRecoveryMarkerCreatedAt = null, + localRecoveryMarkerLastError = null, + remoteRecoveryAttemptId = null, + remoteRecoveryMarkerObservedAt = null, + ) + + // endregion +} + +private class TestRepoError(message: String) : AppError(message) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt new file mode 100644 index 0000000000..faa65324ad --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt @@ -0,0 +1,690 @@ +package to.bitkit.repositories + +import com.synonym.paykit.AccountingAmount +import com.synonym.paykit.AllowanceAccountingHistory +import com.synonym.paykit.AllowanceAccountingState +import com.synonym.paykit.AllowanceAmountRange +import com.synonym.paykit.AllowanceHistoryStatus +import com.synonym.paykit.AllowanceLifecycleState +import com.synonym.paykit.AllowanceLocalRole +import com.synonym.paykit.AllowancePeriod +import com.synonym.paykit.AllowancePeriodLimit +import com.synonym.paykit.AllowanceRecord +import com.synonym.paykit.AllowanceTerms +import com.synonym.paykit.PaymentAccountingScope +import com.synonym.paykit.PaymentAttemptRecord +import com.synonym.paykit.PaymentDisposition +import com.synonym.paykit.PaymentExecutionMode +import com.synonym.paykit.PaymentExecutionStatus +import com.synonym.paykit.PaymentOccurrenceKey +import com.synonym.paykit.PaymentOccurrenceRecord +import org.junit.Test +import org.lightningdevkit.ldknode.Network +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.data.keychain.Keychain +import to.bitkit.services.PaykitReceiverPaths +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +class PaykitAllowanceTest : BaseUnitTest() { + private val fixtures = PaykitAllowanceFixtures + + // region Records + + @Test + fun `record reads back sats, anchor, role and allowlist`() { + val allowlist = listOf(fixtures.lightningIdentifier, fixtures.onchainIdentifier) + val record = fixtures.record( + state = AllowanceLifecycleState.PROPOSED, + terms = fixtures.terms(allowedPaymentEndpointIdentifiers = allowlist), + proposedByMe = true, + ) + + val allowance = checkNotNull(PaykitAllowance.from(record)) + + assertEquals(fixtures.counterpartyKey, allowance.counterparty) + assertEquals(PaykitReceiverPaths.WALLET, allowance.counterpartyReceiverPath) + assertEquals(PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID, allowance.allowanceId) + assertEquals(PaykitAllowance.Role.ALLOWER, allowance.role) + assertTrue(allowance.isAllower) + assertTrue(allowance.isProposedByMe) + assertEquals(AllowanceLifecycleState.PROPOSED, allowance.lifecycleState) + assertEquals(5_000uL, allowance.perPaymentMaxSats) + assertEquals(50_000uL, allowance.monthlyLimitSats) + assertEquals(fixtures.septemberAnchor, allowance.monthlyAnchor) + assertNull(allowance.activeFrom) + assertNull(allowance.expiresAt) + assertEquals(allowlist, allowance.allowedPaymentEndpointIdentifiers) + assertEquals(Instant.parse("2026-09-02T10:00:00Z"), allowance.lastEventAt) + + val received = checkNotNull( + PaykitAllowance.from( + fixtures.record( + localRole = AllowanceLocalRole.ALLOWEE, + state = AllowanceLifecycleState.PROPOSED, + proposedByMe = false, + ), + ), + ) + assertEquals(PaykitAllowance.Role.ALLOWEE, received.role) + assertFalse(received.isAllower) + assertFalse(received.isProposedByMe) + assertTrue(received.isAnswerable) + } + + @Test + fun `record without usable role, terms or asset is skipped`() { + assertNull(PaykitAllowance.from(fixtures.record(localRole = null))) + assertNull(PaykitAllowance.from(fixtures.record(localRole = AllowanceLocalRole.UNKNOWN))) + assertNull(PaykitAllowance.from(fixtures.record(terms = null))) + assertNull(PaykitAllowance.from(fixtures.record(terms = fixtures.terms(asset = "usd")))) + } + + @Test + fun `status maps every lifecycle state`() { + fun status( + state: AllowanceLifecycleState, + proposedByMe: Boolean = true, + terms: AllowanceTerms = fixtures.terms(), + now: Instant = fixtures.now, + ): PaykitAllowance.Status { + val record = fixtures.record(state = state, terms = terms, proposedByMe = proposedByMe) + return checkNotNull(PaykitAllowance.from(record)).status(now) + } + + assertEquals(PaykitAllowance.Status.AWAITING_ANSWER, status(AllowanceLifecycleState.PROPOSED)) + assertEquals( + PaykitAllowance.Status.AWAITING_MY_ANSWER, + status(AllowanceLifecycleState.PROPOSED, proposedByMe = false), + ) + assertEquals(PaykitAllowance.Status.ACTIVE, status(AllowanceLifecycleState.ACCEPTED)) + assertEquals(PaykitAllowance.Status.DECLINED, status(AllowanceLifecycleState.REJECTED)) + assertEquals(PaykitAllowance.Status.ENDED, status(AllowanceLifecycleState.ENDED)) + assertEquals(PaykitAllowance.Status.CONFLICTED, status(AllowanceLifecycleState.CONFLICTED)) + assertEquals(PaykitAllowance.Status.CONFLICTED, status(AllowanceLifecycleState.UNKNOWN)) + + val expiry = Instant.parse("2026-09-20T00:00:00Z") + val expiring = fixtures.terms(expiresAt = "2026-09-20T00:00:00Z") + val accepted = AllowanceLifecycleState.ACCEPTED + assertEquals(PaykitAllowance.Status.ACTIVE, status(accepted, terms = expiring, now = expiry - 1.seconds)) + assertEquals(PaykitAllowance.Status.EXPIRED, status(accepted, terms = expiring, now = expiry)) + assertEquals(PaykitAllowance.Status.EXPIRED, status(accepted, terms = expiring, now = fixtures.now)) + + val start = Instant.parse("2026-10-01T00:00:00Z") + val scheduled = fixtures.terms(activeFrom = "2026-10-01T00:00:00Z") + assertEquals(PaykitAllowance.Status.NOT_YET_ACTIVE, status(accepted, terms = scheduled, now = fixtures.now)) + assertEquals(PaykitAllowance.Status.ACTIVE, status(accepted, terms = scheduled, now = start)) + } + + @Test + fun `sats from bitcoin amount reads the zero minimum`() { + assertEquals(0uL, PaykitAllowance.satsFromBitcoinAmount("0")) + assertEquals(0uL, PaykitAllowance.satsFromBitcoinAmount("0.00000000")) + assertEquals(5_000uL, PaykitAllowance.satsFromBitcoinAmount("0.00005")) + assertEquals(50_000uL, PaykitAllowance.satsFromBitcoinAmount("0.0005")) + assertNull(PaykitAllowance.satsFromBitcoinAmount("abc")) + } + + @Test + fun `bitcoin decimal and trusted time round trip`() { + assertEquals("0.00005", 5_000uL.toBitcoinDecimal()) + assertEquals("0.0005", 50_000uL.toBitcoinDecimal()) + assertEquals("2026-09-24T12:00:00Z", PaykitAllowanceTime.format(fixtures.now)) + val withMillis = Instant.parse("2026-09-24T12:00:00.123456Z") + assertEquals("2026-09-24T12:00:00.123Z", PaykitAllowanceTime.format(withMillis)) + assertEquals(Instant.parse("2026-09-24T12:00:00.123Z"), PaykitAllowanceTime.parse("2026-09-24T12:00:00.123Z")) + assertNull(PaykitAllowanceTime.parse("yesterday")) + } + + // endregion + + // region Monthly window + + @Test + fun `month start uses the UTC calendar month`() { + assertEquals(fixtures.septemberAnchor, PaykitAllowanceTime.monthStart(fixtures.now)) + assertEquals( + fixtures.septemberAnchor, + PaykitAllowanceTime.monthStart(Instant.parse("2026-10-01T01:00:00+02:00")), + ) + assertEquals( + Instant.parse("2026-10-01T00:00:00Z"), + PaykitAllowanceTime.monthStart(Instant.parse("2026-09-30T23:30:00-02:00")), + ) + } + + @Test + fun `monthly window from a first of month anchor`() { + val cases = listOf( + Triple("2026-09-01T00:00:00Z", "2026-09-01T00:00:00Z", "2026-10-01T00:00:00Z"), + Triple("2026-09-24T12:00:00Z", "2026-09-01T00:00:00Z", "2026-10-01T00:00:00Z"), + Triple("2026-09-30T23:59:59Z", "2026-09-01T00:00:00Z", "2026-10-01T00:00:00Z"), + Triple("2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z", "2026-11-01T00:00:00Z"), + Triple("2026-12-31T23:59:59Z", "2026-12-01T00:00:00Z", "2027-01-01T00:00:00Z"), + Triple("2027-02-10T08:00:00Z", "2027-02-01T00:00:00Z", "2027-03-01T00:00:00Z"), + Triple("2026-08-31T23:59:59Z", "2026-08-01T00:00:00Z", "2026-09-01T00:00:00Z"), + Triple("2026-07-15T12:00:00Z", "2026-07-01T00:00:00Z", "2026-08-01T00:00:00Z"), + ) + + for ((date, start, end) in cases) { + val window = PaykitAllowanceTime.monthlyWindow(fixtures.septemberAnchor, Instant.parse(date)) + assertEquals(Instant.parse(start) to Instant.parse(end), window, "window for $date") + } + } + + @Test + fun `monthly window clamps a January 31 anchor in February`() { + val anchor = Instant.parse("2026-01-31T12:30:00Z") + + val midFebruary = PaykitAllowanceTime.monthlyWindow(anchor, Instant.parse("2026-02-15T00:00:00Z")) + assertEquals(anchor, midFebruary.first) + assertEquals(Instant.parse("2026-02-28T12:30:00Z"), midFebruary.second) + + val lateFebruary = PaykitAllowanceTime.monthlyWindow(anchor, Instant.parse("2026-02-28T12:30:00Z")) + assertEquals(Instant.parse("2026-02-28T12:30:00Z"), lateFebruary.first) + } + + /** + * Vectors from paykit-lib `test_anchored_months_clamp_from_original_anchor`: every boundary is counted from the + * original anchor, so the window after a clamped February still ends on March 31. + */ + @Test + fun `monthly window after a clamped February matches paykit anchored arithmetic`() { + val anchor = Instant.parse("2026-01-31T12:30:00Z") + val vectors = listOf( + Triple("2026-02-28T12:30:00Z", "2026-02-28T12:30:00Z", "2026-03-31T12:30:00Z"), + Triple("2026-03-30T12:30:00Z", "2026-02-28T12:30:00Z", "2026-03-31T12:30:00Z"), + Triple("2025-12-01T00:00:00Z", "2025-11-30T12:30:00Z", "2025-12-31T12:30:00Z"), + ) + for ((date, start, end) in vectors) { + val window = PaykitAllowanceTime.monthlyWindow(anchor, Instant.parse(date)) + assertEquals(Instant.parse(start) to Instant.parse(end), window, "window for $date") + } + + val beforeMarchAnchor = PaykitAllowanceTime.monthlyWindow( + Instant.parse("2026-03-31T00:00:00Z"), + Instant.parse("2026-01-15T00:00:00Z"), + ) + assertEquals(Instant.parse("2025-12-31T00:00:00Z"), beforeMarchAnchor.first) + assertEquals(Instant.parse("2026-01-31T00:00:00Z"), beforeMarchAnchor.second) + + val allowance = fixtures.allowance(monthlyAnchor = anchor) + val lateMarchAttempt = fixtures.capacityAttempt(sats = 50_000uL, at = "2026-03-29T09:00:00Z") + assertFalse( + PaykitAllowanceCapacity.fits( + 1_000uL, + allowance, + listOf(lateMarchAttempt), + Instant.parse("2026-03-30T12:30:00Z"), + ), + "A March 29 attempt at the cap must count on March 30", + ) + } + + // endregion + + // region Capacity + + @Test + fun `capacity fits under the cap`() { + val attempts = listOf(fixtures.capacityAttempt(sats = 20_000uL, at = "2026-09-05T10:00:00Z")) + + assertEquals(20_000uL, fixtures.usedSats(attempts)) + assertTrue(PaykitAllowanceCapacity.fits(5_000uL, fixtures.allowance(), attempts, fixtures.now)) + } + + @Test + fun `capacity fits exactly at the cap and rejects one sat over`() { + val attempts = listOf( + fixtures.capacityAttempt(sats = 20_000uL, at = "2026-09-05T10:00:00Z"), + fixtures.capacityAttempt(sats = 25_000uL, at = "2026-09-12T10:00:00Z"), + ) + + assertTrue(PaykitAllowanceCapacity.fits(5_000uL, fixtures.allowance(), attempts, fixtures.now)) + val noPerPaymentMaximum = fixtures.allowance(perPaymentMaxSats = null) + assertFalse(PaykitAllowanceCapacity.fits(5_001uL, noPerPaymentMaximum, attempts, fixtures.now)) + } + + @Test + fun `capacity rejects over the per payment maximum`() { + val allowance = fixtures.allowance() + + assertTrue(PaykitAllowanceCapacity.fits(5_000uL, allowance, emptyList(), fixtures.now)) + assertFalse(PaykitAllowanceCapacity.fits(5_001uL, allowance, emptyList(), fixtures.now)) + } + + @Test + fun `capacity ignores failed attempts, previous months and other allowances`() { + val attempts = listOf( + fixtures.capacityAttempt(sats = 45_000uL, at = "2026-09-05T10:00:00Z", isLive = false), + fixtures.capacityAttempt(sats = 50_000uL, at = "2026-08-31T23:59:59Z"), + fixtures.capacityAttempt( + allowanceId = PaykitAllowanceFixtures.SERVER_ALLOWANCE_ID, + sats = 50_000uL, + at = "2026-09-10T10:00:00Z", + ), + fixtures.capacityAttempt(sats = 1_000uL, at = "2026-09-01T00:00:00Z"), + fixtures.capacityAttempt(sats = 10_000uL, at = "2026-09-12T10:00:00Z"), + ) + + assertEquals(11_000uL, fixtures.usedSats(attempts)) + assertTrue(PaykitAllowanceCapacity.fits(5_000uL, fixtures.allowance(), attempts, fixtures.now)) + } + + @Test + fun `capacity without a monthly limit checks only the per payment maximum`() { + val allowance = fixtures.allowance(monthlyLimitSats = null) + val attempts = listOf(fixtures.capacityAttempt(sats = 1_000_000uL, at = "2026-09-05T10:00:00Z")) + + assertTrue(PaykitAllowanceCapacity.fits(5_000uL, allowance, attempts, fixtures.now)) + } + + @Test + fun `attempts from history keep automatic allowance attempts`() { + val history = AllowanceAccountingHistory( + associations = emptyList(), + occurrences = listOf( + fixtures.occurrence( + requestId = "req-1", + attempts = listOf( + fixtures.attemptRecord( + id = "failed", + amount = "0.0001", + admittedAt = "2026-09-02T10:00:00Z", + status = PaymentExecutionStatus.FAILED, + ), + fixtures.attemptRecord( + id = "paid", + amount = "0.0001", + admittedAt = "2026-09-03T10:00:00Z", + status = PaymentExecutionStatus.SUCCEEDED, + ), + ), + ), + fixtures.occurrence( + requestId = "req-2", + attempts = listOf( + fixtures.attemptRecord( + id = "manual", + mode = PaymentExecutionMode.MANUAL, + allowanceId = null, + status = PaymentExecutionStatus.SUCCEEDED, + ), + fixtures.attemptRecord( + id = "unattributed", + allowanceId = null, + status = PaymentExecutionStatus.SUCCEEDED, + ), + fixtures.attemptRecord( + id = "open", + amount = "0.00002", + admittedAt = "2026-09-05T10:00:00Z", + status = PaymentExecutionStatus.SUBMITTED, + ), + ), + ), + ), + watermarks = emptyList(), + ) + + val attempts = PaykitAllowanceCapacity.attempts(history) + + val walletId = PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID + assertEquals( + listOf( + PaykitAllowanceCapacity.Attempt(walletId, 10_000uL, Instant.parse("2026-09-02T10:00:00Z"), false), + PaykitAllowanceCapacity.Attempt(walletId, 10_000uL, Instant.parse("2026-09-03T10:00:00Z"), true), + PaykitAllowanceCapacity.Attempt(walletId, 2_000uL, Instant.parse("2026-09-05T10:00:00Z"), true), + ), + attempts, + ) + } + + @Test + fun `status and capacity use the given time`() { + val allowance = fixtures.allowance(expiresAt = fixtures.now + 30.days) + val attempts = listOf(fixtures.capacityAttempt(sats = 50_000uL, at = "2026-09-10T10:00:00Z")) + + assertEquals(PaykitAllowance.Status.ACTIVE, allowance.status(fixtures.now)) + assertEquals(PaykitAllowance.Status.EXPIRED, allowance.status(fixtures.now + 30.days)) + assertFalse(PaykitAllowanceCapacity.fits(1uL, allowance, attempts, fixtures.now)) + assertTrue(PaykitAllowanceCapacity.fits(1uL, allowance, attempts, Instant.parse("2026-10-01T00:00:00Z"))) + } + + // endregion + + // region Grouping and terms + + @Test + fun `ordered receiver paths keep supported links with the wallet link first`() { + assertEquals( + listOf(PaykitReceiverPaths.WALLET, PaykitReceiverPaths.SERVER), + PaykitAllowanceRepo.orderedReceiverPaths( + listOf(PaykitReceiverPaths.SERVER, "a/other", PaykitReceiverPaths.WALLET, PaykitReceiverPaths.SERVER), + ), + ) + assertEquals( + listOf(PaykitReceiverPaths.SERVER), + PaykitAllowanceRepo.orderedReceiverPaths(listOf(PaykitReceiverPaths.SERVER)), + ) + assertEquals(emptyList(), PaykitAllowanceRepo.orderedReceiverPaths(emptyList())) + } + + @Test + fun `entry primary prefers an accepted link, then the wallet link`() { + val server = fixtures.allowance( + allowanceId = PaykitAllowanceFixtures.SERVER_ALLOWANCE_ID, + receiverPath = PaykitReceiverPaths.SERVER, + perPaymentMaxSats = 1uL, + ) + val wallet = fixtures.allowance() + + val entry = PaykitAllowanceEntry(id = "group", allowances = listOf(server, wallet), limits = fixtures.limits) + assertEquals(PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID, entry.primary.allowanceId) + assertEquals(5_000uL, entry.perPaymentMaxSats) + + val serverOnly = PaykitAllowanceEntry(id = "server", allowances = listOf(server), limits = null) + assertEquals(PaykitAllowanceFixtures.SERVER_ALLOWANCE_ID, serverOnly.primary.allowanceId) + + val walletDeclined = wallet.copy(lifecycleState = AllowanceLifecycleState.REJECTED) + val acceptedOnServer = PaykitAllowanceEntry(id = "g", listOf(walletDeclined, server), limits = null) + assertEquals(PaykitAllowanceFixtures.SERVER_ALLOWANCE_ID, acceptedOnServer.primary.allowanceId) + + val bothProposed = listOf( + server.copy(lifecycleState = AllowanceLifecycleState.PROPOSED), + wallet.copy(lifecycleState = AllowanceLifecycleState.PROPOSED), + ) + val proposed = PaykitAllowanceEntry(id = "p", allowances = bothProposed, limits = null) + assertEquals(PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID, proposed.primary.allowanceId) + } + + @Test + fun `allowed endpoints are bolt11 and the network's on-chain methods`() { + assertEquals( + listOf( + "btc-lightning-bolt11", + "btc-regtest-p2tr", + "btc-regtest-p2wpkh", + "btc-regtest-p2sh", + "btc-regtest-p2pkh", + ), + PaykitAllowanceRepo.allowedPaymentEndpointIdentifiers(Network.REGTEST), + ) + } + + // endregion + + // region Store + + @Test + fun `store keeps one state per identity and survives a corrupt value`() = test { + val keychain = mock() + var stored: String? = null + whenever(keychain.loadString(any())).thenAnswer { stored } + whenever(keychain.upsertString(any(), any())).doSuspendableAnswer { stored = it.getArgument(1) } + val store = PaykitAllowanceStore(keychain) + val entry = PaykitAllowanceLocalState.JournalEntry( + attemptId = "attempt-1", + isAutomatic = true, + requestId = fixtures.paymentRequest().id, + allowanceId = PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID, + amountSats = 1_000uL, + paymentEndpointIdentifier = fixtures.lightningIdentifier, + paymentHash = "ab".repeat(32), + stage = PaykitAllowanceLocalState.Stage.SENT, + createdAtMillis = fixtures.now.toEpochMilliseconds(), + ) + val group = PaykitAllowanceLocalState.Group( + id = "group-1", + counterparty = fixtures.counterpartyKey, + limits = fixtures.limits, + allowanceIds = listOf(PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID), + createdAtMillis = 1L, + ) + val state = PaykitAllowanceLocalState( + groups = listOf(group), + journal = listOf(entry), + presentedProposalIds = setOf("proposal"), + notifiedRequestIds = setOf("request"), + lastTrustedTimeMillis = 42L, + ) + + store.save(fixtures.identityKey, state) + + assertEquals(state, store.load(fixtures.identityKey)) + assertEquals(PaykitAllowanceLocalState(), store.load(fixtures.otherCounterpartyKey)) + val loaded = store.load(fixtures.identityKey) + assertEquals(group, loaded.group(containing = PaykitAllowanceFixtures.WALLET_ALLOWANCE_ID)) + + stored = "{not json" + assertEquals(PaykitAllowanceLocalState(), store.load(fixtures.identityKey)) + } + + // endregion +} + +/** Shared builders for the Allowance suites. */ +internal object PaykitAllowanceFixtures { + const val WALLET_ALLOWANCE_ID = "allowance-wallet" + const val SERVER_ALLOWANCE_ID = "allowance-server" + val identityKey = "pubky" + "z".repeat(52) + val counterpartyKey = "pubky" + "y".repeat(52) + val otherCounterpartyKey = "pubky" + "x".repeat(52) + val lightningIdentifier: String get() = MethodId.Bolt11.rawValue + val onchainIdentifier: String get() = MethodId.P2wpkh.rawValue + val now: Instant = Instant.parse("2026-09-24T12:00:00Z") + val septemberAnchor: Instant = Instant.parse("2026-09-01T00:00:00Z") + val limits = PaykitAllowanceLimits( + perPaymentUsd = 5, + monthlyUsd = 50, + perPaymentSats = 5_000uL, + monthlySats = 50_000uL, + ) + + @Suppress("LongParameterList") + fun terms( + perPaymentMaximum: String? = "0.00005", + monthlyLimit: String? = "0.0005", + anchor: String? = "2026-09-01T00:00:00Z", + activeFrom: String? = null, + expiresAt: String? = null, + asset: String = PaykitIssuerInterop.BITCOIN_ASSET, + allowedPaymentEndpointIdentifiers: List? = listOf(lightningIdentifier), + ): AllowanceTerms { + val termsAsset = asset + val termsActiveFrom = activeFrom + val termsExpiresAt = expiresAt + val allowlist = allowedPaymentEndpointIdentifiers + val periodAnchor = anchor + val range = perPaymentMaximum?.let { max -> + mock { + on { minimum() } doReturn "0" + on { maximum() } doReturn max + } + } + val monthlyPeriod = mock { + on { kind() } doReturn "anchored" + on { every() } doReturn 1uL + on { unit() } doReturn "month" + on { anchor() } doReturn periodAnchor + } + val periodLimit = mock { + on { amountLimit() } doReturn monthlyLimit + on { paymentCountLimit() } doReturn null + on { period() } doReturn monthlyPeriod + } + return mock { + on { asset() } doReturn termsAsset + on { perPaymentAmount() } doReturn range + on { periodLimits() } doReturn listOf(periodLimit) + on { lifetimeAmountLimit() } doReturn null + on { activeFrom() } doReturn termsActiveFrom + on { expiresAt() } doReturn termsExpiresAt + on { allowedPaymentEndpointIdentifiers() } doReturn allowlist + } + } + + @Suppress("LongParameterList") + fun record( + allowanceId: String = WALLET_ALLOWANCE_ID, + counterparty: String = counterpartyKey, + receiverPath: String = PaykitReceiverPaths.WALLET, + localRole: AllowanceLocalRole? = AllowanceLocalRole.ALLOWER, + state: AllowanceLifecycleState = AllowanceLifecycleState.ACCEPTED, + historyStatus: AllowanceHistoryStatus = AllowanceHistoryStatus.CONSISTENT, + terms: AllowanceTerms? = terms(), + proposedByMe: Boolean = true, + lastEventAt: String? = "2026-09-02T10:00:00Z", + ) = AllowanceRecord( + counterparty = counterparty, + counterpartyReceiverPath = receiverPath, + allowanceId = allowanceId, + localRole = localRole, + state = state, + historyStatus = historyStatus, + proposalEventId = "proposal-$allowanceId", + terms = terms, + proposalStreamItemId = if (proposedByMe) null else 1uL, + proposalOutboundMessageId = if (proposedByMe) 1uL else null, + proposalOutboundStatus = null, + acceptanceEventId = null, + acceptanceOutboundStatus = null, + rejectionEventId = null, + rejectionOutboundStatus = null, + endEventId = null, + endOutboundStatus = null, + pendingCausalEventIds = emptyList(), + conflictEventIds = emptyList(), + lastStreamItemId = null, + lastOutboundMessageId = null, + lastOutboundStatus = null, + lastEventAt = lastEventAt, + invalidReason = null, + ) + + @Suppress("LongParameterList") + fun allowance( + allowanceId: String = WALLET_ALLOWANCE_ID, + counterparty: String = counterpartyKey, + receiverPath: String = PaykitReceiverPaths.WALLET, + role: PaykitAllowance.Role = PaykitAllowance.Role.ALLOWER, + state: AllowanceLifecycleState = AllowanceLifecycleState.ACCEPTED, + perPaymentMaxSats: ULong? = 5_000uL, + monthlyLimitSats: ULong? = 50_000uL, + monthlyAnchor: Instant? = septemberAnchor, + expiresAt: Instant? = null, + lastEventAt: Instant? = null, + ) = PaykitAllowance( + id = PaykitAllowance.Id(counterparty, receiverPath, allowanceId), + role = role, + lifecycleState = state, + isProposedByMe = role == PaykitAllowance.Role.ALLOWER, + perPaymentMaxSats = perPaymentMaxSats, + monthlyLimitSats = monthlyLimitSats, + monthlyAnchor = monthlyAnchor, + expiresAt = expiresAt, + allowedPaymentEndpointIdentifiers = listOf(lightningIdentifier), + lastEventAt = lastEventAt, + ) + + fun capacityAttempt( + allowanceId: String = WALLET_ALLOWANCE_ID, + sats: ULong, + at: String, + isLive: Boolean = true, + ) = PaykitAllowanceCapacity.Attempt(allowanceId, sats, Instant.parse(at), isLive) + + fun usedSats(attempts: List): ULong = + PaykitAllowanceCapacity.usedSats(WALLET_ALLOWANCE_ID, attempts, septemberAnchor, now) + + fun accountingAmount(amount: String, currency: String = PaykitIssuerInterop.BITCOIN_ASSET): AccountingAmount = + mock { + on { value() } doReturn amount + on { asset() } doReturn currency + } + + @Suppress("LongParameterList") + fun attemptRecord( + id: String, + mode: PaymentExecutionMode = PaymentExecutionMode.AUTOMATIC, + allowanceId: String? = WALLET_ALLOWANCE_ID, + amount: String = "0.00001", + admittedAt: String = "2026-09-24T12:00:00Z", + status: PaymentExecutionStatus, + epoch: String = "epoch-1", + ) = PaymentAttemptRecord( + attemptId = id, + mode = mode, + allowanceId = allowanceId, + associationRevision = allowanceId?.let { 1uL }, + amount = accountingAmount(amount), + admittedAt = admittedAt, + status = status, + epoch = epoch, + ) + + fun accountingScope(paymentRequestId: String) = PaymentAccountingScope( + localPublicKey = identityKey, + localReceiverPath = PaykitReceiverPaths.WALLET, + counterparty = counterpartyKey, + counterpartyReceiverPath = PaykitReceiverPaths.WALLET, + paymentRequestId = paymentRequestId, + ) + + fun occurrence( + requestId: String, + attempts: List, + allowanceId: String? = WALLET_ALLOWANCE_ID, + ) = PaymentOccurrenceRecord( + key = PaymentOccurrenceKey(request = accountingScope(requestId), billingPeriod = null), + disposition = PaymentDisposition.Automatic, + allowanceId = allowanceId, + associationRevision = allowanceId?.let { 1uL }, + attempts = attempts, + ) + + fun accountingState( + revision: ULong = 1uL, + epoch: String = "epoch-1", + requiresReconciliation: Boolean = false, + occurrences: List = emptyList(), + ) = AllowanceAccountingState( + revision = revision, + epoch = epoch, + requiresReconciliation = requiresReconciliation, + history = AllowanceAccountingHistory( + associations = emptyList(), + occurrences = occurrences, + watermarks = emptyList(), + ), + ) + + @Suppress("LongParameterList") + fun paymentRequest( + id: String = "550e8400-e29b-41d4-a716-446655440001", + counterparty: String = counterpartyKey, + receiverPath: String = PaykitReceiverPaths.WALLET, + amountValue: String = "0.00001", + amountSats: ULong = 1_000uL, + createdAt: String = "2026-09-24T11:00:00Z", + ) = PaykitPaymentRequest( + paymentRequestId = id, + counterparty = counterparty, + counterpartyReceiverPath = receiverPath, + amountValue = amountValue, + amountSats = amountSats, + createdAt = Instant.parse(createdAt), + expiresAt = null, + acceptedPaymentEndpointIdentifiers = listOf(lightningIdentifier), + ) +} diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 06092a9246..d626a59b87 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -50,6 +50,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { private const val PAYMENT_REQUEST_ID = "550e8400-e29b-41d4-a716-446655440000" private const val PAYMENT_HASH = "66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925" private const val ONCHAIN_ADDRESS = "bcrt1qpaymentproof" + private const val ALLOWANCE_ID = "4f1c2d3e-5a6b-4c7d-8e9f-0a1b2c3d4e5f" private val PREIMAGE = "00".repeat(32) } @@ -118,7 +119,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() val request = paymentRequest(MethodId.Bolt11.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) .thenThrow(IllegalStateException("temporary failure")) .thenReturn(record) val firstRepo = paymentProofRepo() @@ -140,6 +141,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = endpointCaptor.capture(), proofJson = proofCaptor.capture(), billingPeriod = isNull(), + allowanceId = isNull(), ) assertEquals(MethodId.Bolt11.rawValue, endpointCaptor.lastValue) assertEquals( @@ -163,7 +165,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestRecord(paymentRequestId = secondPaymentRequestId), ), ) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) .thenThrow(IllegalStateException("temporary failure")) .thenReturn(paymentRequestRecord(paymentRequestId = secondPaymentRequestId)) @@ -177,6 +179,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = any(), proofJson = any(), billingPeriod = isNull(), + allowanceId = isNull(), ) assertEquals(listOf(PAYMENT_REQUEST_ID, secondPaymentRequestId), paymentRequestIdCaptor.allValues) assertEquals(listOf(PAYMENT_REQUEST_ID), storedProofs.map { it.requestId.paymentRequestId }) @@ -197,7 +200,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } whenever(lightningRepo.getPayments()).thenReturn(Result.success(listOf(payment))) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + .thenReturn(record) val firstRepo = paymentProofRepo() firstRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() @@ -215,6 +219,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = any(), proofJson = proofCaptor.capture(), billingPeriod = isNull(), + allowanceId = isNull(), ) assertEquals( """{"data":"$PREIMAGE","type":"${PaykitPaymentProofKind.Lightning.type}"}""", @@ -223,18 +228,45 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.isEmpty()) } + @Test + fun `allowance proof is submitted with its allowance id`() = test { + val record = paymentRequestRecord() + val request = paymentRequest(MethodId.Bolt11.rawValue) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), any())) + .thenReturn(record) + val sut = paymentProofRepo() + + sut.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning, ALLOWANCE_ID).getOrThrow() + sut.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + assertEquals(ALLOWANCE_ID, storedProofs.single().allowanceId) + sut.completeLightningPayment(PAYMENT_HASH, PREIMAGE) + + verify(paykitSdkService).submitPaymentProof( + counterparty = any(), + counterpartyReceiverPath = any(), + paymentRequestId = any(), + paymentEndpointIdentifier = eq(MethodId.Bolt11.rawValue), + proofJson = any(), + billingPeriod = isNull(), + allowanceId = eq(ALLOWANCE_ID), + ) + assertTrue(storedProofs.isEmpty()) + } + @Test fun `lightning proof completes without prepared proof`() = test { val record = paymentRequestRecord() val request = paymentRequest(MethodId.Bolt11.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + .thenReturn(record) val repo = paymentProofRepo() repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) assertTrue(storedProofs.isEmpty()) } @@ -248,7 +280,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.completeLightningPayment(PAYMENT_HASH, "01".repeat(32)) assertNull(storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -271,7 +303,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) assertTrue(storedProofs.isEmpty()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -284,7 +316,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.failLightningPayment(PAYMENT_HASH) assertTrue(storedProofs.isEmpty()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -298,7 +330,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever(lightningRepo.getPayments()).thenReturn(Result.success(emptyList())) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + .thenReturn(record) for (error in errors) { val request = paymentRequest(MethodId.Bolt11.rawValue) val repo = paymentProofRepo() @@ -319,7 +352,15 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { restartedRepo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) assertTrue(storedProofs.isEmpty()) } - verify(paykitSdkService, times(errors.size)).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService, times(errors.size)).submitPaymentProof( + any(), + any(), + any(), + any(), + any(), + isNull(), + isNull(), + ) } @Test @@ -351,7 +392,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + .thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() @@ -368,6 +410,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { endpointCaptor.capture(), proofCaptor.capture(), isNull(), + isNull(), ) assertEquals(MethodId.P2wpkh.rawValue, endpointCaptor.firstValue) assertEquals( @@ -433,12 +476,13 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(endpoint) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + .thenReturn(record) val repo = paymentProofRepo() repo.completeOnchainPayment(request, txid, endpoint) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(endpoint), any(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(endpoint), any(), isNull(), isNull()) assertTrue(storedProofs.isEmpty()) } @@ -451,7 +495,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.Bolt11.rawValue, billingPeriod = period) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull())) + .thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() @@ -466,6 +511,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = any(), proofJson = any(), billingPeriod = periodCaptor.capture(), + allowanceId = isNull(), ) assertEquals(period, periodCaptor.firstValue) } @@ -490,14 +536,15 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord(listOf(existingProof)) val request = paymentRequest(MethodId.Bolt11.rawValue, billingPeriod = currentPeriod) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull())) + .thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), any()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) } @Test @@ -538,7 +585,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + .thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() @@ -546,7 +594,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { shouldFailNextSave = true repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) assertTrue(storedProofs.isEmpty()) } @@ -556,7 +604,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) .thenThrow(IllegalStateException("transient submission failure")) val repo = paymentProofRepo() @@ -566,7 +614,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) assertEquals(txid, storedProofs.single().proofData) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) } @Test @@ -575,7 +623,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + .thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() @@ -583,7 +632,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { shouldFailProofRemoval = true repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) assertEquals(txid, storedProofs.single().proofData) } @@ -594,7 +643,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(endpoint) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + .thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, endpoint, PaykitPaymentProofKind.Onchain).getOrThrow() @@ -611,6 +661,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = endpointCaptor.capture(), proofJson = proofCaptor.capture(), billingPeriod = isNull(), + allowanceId = isNull(), ) assertEquals(endpoint, endpointCaptor.firstValue) assertEquals( @@ -626,7 +677,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() val request = paymentRequest(MethodId.P2wpkh.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) + .thenReturn(record) whenever( onchainPaymentLookup.transactionId( ONCHAIN_ADDRESS, @@ -650,6 +702,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = eq(MethodId.P2wpkh.rawValue), proofJson = proofCaptor.capture(), billingPeriod = isNull(), + allowanceId = isNull(), ) assertTrue(proofCaptor.firstValue.contains(txid)) } @@ -665,7 +718,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestRecord(paymentRequestId = secondPaymentRequestId), ), ) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull())) .thenReturn(paymentRequestRecord()) whenever(onchainPaymentLookup.transactionId(any(), any(), any(), any())) .thenReturn("ab".repeat(32), "cd".repeat(32)) @@ -706,7 +759,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(setOf(oldTransactionId), storedProofs.single().onchainMatchingTransactionIdsBeforeAttempt) assertNull(storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any(), isNull()) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt new file mode 100644 index 0000000000..2e4ae32b6b --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt @@ -0,0 +1,246 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.LightningInvoice +import com.synonym.bitkitcore.NetworkType +import com.synonym.bitkitcore.Scanner +import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PaymentAmountContext +import com.synonym.paykit.PrivatePaymentResolutionState +import com.synonym.paykit.PrivatePaymentResolutionStatus +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.PrivatePaykitCacheData +import to.bitkit.data.PrivatePaykitCacheStore +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.ext.toHex +import to.bitkit.models.NodeLifecycleState +import to.bitkit.services.CoreService +import to.bitkit.services.PaykitPreparedPrivateContactPayment +import to.bitkit.services.PaykitPrivateContactPaymentResolution +import to.bitkit.services.PaykitReceiverPaths +import to.bitkit.services.PaykitResolvedPaymentEndpoint +import to.bitkit.services.PaykitSdkService +import to.bitkit.services.PubkyService +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Clock +import kotlin.time.Instant + +class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) { + companion object { + private const val CONTACT_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val PRIVATE_ADDRESS = "bcrt1qs04g2ka4pr9s3mv73nu32tvfy7r3cxd27wkyu8" + private const val PRIVATE_BOLT11 = "lnbcrt1private" + private const val PRIVATE_LNURL = "lnurl1private" + private const val NOW_SECONDS = 1_700_000_000L + private val PAYMENT_HASH = byteArrayOf(9, 9, 9) + } + + private val paykitSdkService = mock() + private val cacheStore = mock() + private val settingsStore = mock() + private val lightningRepo = mock() + private val publicPaykitRepo = mock() + private val coreService = mock() + private val clock = mock() + private lateinit var sut: PrivatePaykitRepo + + @Before + fun setUp() = test { + whenever(cacheStore.data).thenReturn(MutableStateFlow(PrivatePaykitCacheData())) + whenever(settingsStore.data).thenReturn(MutableStateFlow(SettingsData())) + whenever(lightningRepo.lightningState) + .thenReturn(MutableStateFlow(LightningState(nodeLifecycleState = NodeLifecycleState.Running))) + whenever(lightningRepo.getPayments()).thenReturn(Result.success(emptyList())) + whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(NOW_SECONDS)) + whenever(publicPaykitRepo.payableEndpoints(any())).thenAnswer { it.getArgument>(0) } + whenever(coreService.isAddressUsed(any())).thenReturn(false) + PublicPaykitRepo.lightningRouteHintsValidator = { true } + + sut = PrivatePaykitRepo( + ioDispatcher = testDispatcher, + paykitSdkService = paykitSdkService, + pubkyService = mock(), + cacheStore = cacheStore, + settingsStore = settingsStore, + addressReservationRepo = mock(), + lightningRepo = lightningRepo, + walletRepo = mock(), + publicPaykitRepo = publicPaykitRepo, + coreService = coreService, + clock = clock, + ) + } + + @After + fun tearDown() { + PublicPaykitRepo.lightningRouteHintsValidator = null + } + + @Test + fun `allowance payment prefers an exact bolt11 invoice among accepted private endpoints`() = test { + stubResolution( + resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), + resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), + ) + stubInvoice(amountSats = 2_500uL) + val request = paymentRequest() + + val payment = sut.resolveAllowancePayment(request, eligible(MethodId.Bolt11, MethodId.P2wpkh)).getOrThrow() + + val invoiceEndpoint = PublicPaykitRepo.parseEndpoint(MethodId.Bolt11.rawValue, payload(PRIVATE_BOLT11)) + assertEquals( + PrivatePaykitAllowancePayment( + endpoint = checkNotNull(invoiceEndpoint), + context = PrivatePaykitPaymentContext(PaykitReceiverPaths.SERVER, 7uL), + lightningPaymentHash = PAYMENT_HASH.toHex(), + lightningInvoiceHasAmount = true, + ), + payment, + ) + val amountCaptor = argumentCaptor() + verify(paykitSdkService).prepareAndResolvePrivateContactPayment( + eq(CONTACT_KEY), + eq(PaykitReceiverPaths.SERVER), + eq(null), + amountCaptor.capture(), + ) + assertEquals(PaymentAmountContext("0.000025", "btc"), amountCaptor.firstValue) + } + + @Test + fun `allowance payment skips an invoice for another amount and falls back to on-chain`() = test { + stubResolution( + resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), + resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), + ) + stubInvoice(amountSats = 1_000uL) + + val payment = sut.resolveAllowancePayment(paymentRequest(), eligible(MethodId.Bolt11, MethodId.P2wpkh)) + .getOrThrow() + + assertEquals(MethodId.P2wpkh, payment?.endpoint?.methodId) + assertEquals(PRIVATE_ADDRESS, payment?.endpoint?.value) + assertNull(payment?.lightningPaymentHash) + } + + @Test + fun `amount-less invoice qualifies for the requested amount`() = test { + stubResolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11)) + stubInvoice(amountSats = 0uL) + + val payment = sut.resolveAllowancePayment(paymentRequest(), eligible(MethodId.Bolt11)).getOrThrow() + + assertEquals(PRIVATE_BOLT11, payment?.endpoint?.value) + assertEquals(false, payment?.lightningInvoiceHasAmount) + } + + @Test + fun `allowance payment uses only endpoints the allowance and the request both accept`() = test { + stubResolution( + resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), + resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), + ) + stubInvoice(amountSats = 2_500uL) + val request = paymentRequest(accepted = eligible(MethodId.P2wpkh)) + + val payment = sut.resolveAllowancePayment(request, eligible(MethodId.Bolt11, MethodId.P2wpkh)).getOrThrow() + val none = sut.resolveAllowancePayment(request, eligible(MethodId.Bolt11)).getOrThrow() + + assertEquals(MethodId.P2wpkh, payment?.endpoint?.methodId) + assertNull(none) + } + + @Test + fun `lnurl and used addresses never qualify`() = test { + stubResolution( + resolvedEndpoint(MethodId.Lnurl, PRIVATE_LNURL), + resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), + ) + whenever(coreService.isAddressUsed(PRIVATE_ADDRESS)).thenReturn(true) + val request = paymentRequest(accepted = eligible(MethodId.Lnurl, MethodId.P2wpkh)) + + assertNull(sut.resolveAllowancePayment(request, eligible(MethodId.Lnurl, MethodId.P2wpkh)).getOrThrow()) + } + + @Test + fun `allowance payment needs a private payment list`() = test { + stubResolution(resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), version = null) + + assertNull(sut.resolveAllowancePayment(paymentRequest(), eligible(MethodId.P2wpkh)).getOrThrow()) + } + + @Test + fun `expired request is never resolved`() = test { + val expired = paymentRequest().copy(expiresAt = Instant.fromEpochSeconds(NOW_SECONDS - 1)) + + assertNull(sut.resolveAllowancePayment(expired, eligible(MethodId.Bolt11)).getOrThrow()) + verify(paykitSdkService, never()).prepareAndResolvePrivateContactPayment(any(), any(), anyOrNull(), anyOrNull()) + } + + private suspend fun stubResolution(vararg endpoints: PaykitResolvedPaymentEndpoint, version: ULong? = 7uL) { + whenever(paykitSdkService.prepareAndResolvePrivateContactPayment(any(), any(), anyOrNull(), anyOrNull())) + .thenReturn( + PaykitPreparedPrivateContactPayment( + resolution = PaykitPrivateContactPaymentResolution( + status = PrivatePaymentResolutionStatus.PAYABLE, + state = PrivatePaymentResolutionState.AVAILABLE, + privatePaymentListVersion = version, + payableEndpoints = endpoints.toList(), + ), + linkState = LinkedPeerState.LINKED, + ), + ) + } + + private suspend fun stubInvoice(amountSats: ULong) { + whenever(coreService.decode(PRIVATE_BOLT11)).thenReturn( + Scanner.Lightning( + LightningInvoice( + bolt11 = PRIVATE_BOLT11, + paymentHash = PAYMENT_HASH, + amountSatoshis = amountSats, + timestampSeconds = NOW_SECONDS.toULong(), + expirySeconds = 86_400uL, + isExpired = false, + description = "", + networkType = NetworkType.REGTEST, + payeeNodeId = null, + ), + ), + ) + } + + private fun eligible(vararg methods: MethodId) = methods.map { it.rawValue } + + private fun payload(value: String) = PublicPaykitRepo.serializePayload(value) + + private fun resolvedEndpoint(methodId: MethodId, value: String) = PaykitResolvedPaymentEndpoint( + identifier = methodId.rawValue, + payload = payload(value), + ) + + private fun paymentRequest(accepted: List = eligible(MethodId.Bolt11, MethodId.P2wpkh)) = + PaykitPaymentRequest( + paymentRequestId = "request-id", + counterparty = CONTACT_KEY, + counterpartyReceiverPath = PaykitReceiverPaths.SERVER, + amountValue = "0.000025", + amountSats = 2_500uL, + expiresAt = Instant.fromEpochSeconds(NOW_SECONDS + 60), + acceptedPaymentEndpointIdentifiers = accepted, + ) +} From b07d4d1f59449bbc1211318d8f4f88c775efd3c7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 17:49:32 +0200 Subject: [PATCH 06/12] feat: add the allowances tab and allowance sheets --- app/src/main/java/to/bitkit/ui/ContentView.kt | 3 +- .../java/to/bitkit/ui/components/Slider.kt | 31 +- .../paymentrequests/PaymentRequestsScreen.kt | 18 +- .../screens/subscriptions/AllowanceSheet.kt | 588 ++++++++++++++++++ .../screens/subscriptions/AllowancesScreen.kt | 285 +++++++++ .../subscriptions/AllowancesViewModel.kt | 348 +++++++++++ .../subscriptions/SubscriptionsScreen.kt | 23 +- app/src/main/res/values/strings.xml | 47 ++ .../subscriptions/AllowancesViewModelTest.kt | 230 +++++++ docs/screens-map.md | 1 + 10 files changed, 1563 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesScreen.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModel.kt create mode 100644 app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index ac4ca0bea9..9640e12431 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -122,6 +122,7 @@ import to.bitkit.ui.screens.settings.VssDebugScreen import to.bitkit.ui.screens.shop.ShopIntroScreen import to.bitkit.ui.screens.shop.shopDiscover.ShopDiscoverScreen import to.bitkit.ui.screens.shop.shopWebView.ShopWebViewScreen +import to.bitkit.ui.screens.subscriptions.AllowanceSheet import to.bitkit.ui.screens.subscriptions.CreateSubscriptionSheet import to.bitkit.ui.screens.subscriptions.SubscriptionDetailScreen import to.bitkit.ui.screens.subscriptions.SubscriptionSheet @@ -569,7 +570,7 @@ fun ContentView( is Sheet.Subscription -> SubscriptionSheet(appViewModel, sheet.route) - is Sheet.Allowance -> Unit // CONTRACT: UI worker renders AllowanceSheet(sheet.route) here + is Sheet.Allowance -> AllowanceSheet(appViewModel, sheet.route) is Sheet.ActivityDateRangeSelector -> DateRangeSelectorSheet() is Sheet.ActivityTagSelector -> TagSelectorSheet() diff --git a/app/src/main/java/to/bitkit/ui/components/Slider.kt b/app/src/main/java/to/bitkit/ui/components/Slider.kt index b27c197ff3..54aa1e8731 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -13,7 +13,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.systemGestureExclusion import androidx.compose.runtime.Composable @@ -32,12 +32,14 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.semantics.progressBarRangeInfo import androidx.compose.ui.semantics.semantics @@ -51,6 +53,7 @@ import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.launch +import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import kotlin.math.abs @@ -73,6 +76,9 @@ fun Slider( onValueChange: (Int) -> Unit, modifier: Modifier = Modifier, formatLabel: (Int) -> String = { "$$it" }, + activeColor: Color = Colors.Green, + trackColor: Color = Colors.Green32, + stopTestTag: ((index: Int) -> String)? = null, ) { val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() @@ -173,7 +179,7 @@ fun Slider( val cornerRadius = density.run { 3.dp.toPx() } drawRoundRect( - color = Colors.Green32, + color = trackColor, topLeft = Offset(0f, trackY - trackHeight / 2), size = Size(size.width, trackHeight), cornerRadius = CornerRadius(cornerRadius), @@ -181,7 +187,7 @@ fun Slider( if (knobX > 0f) { drawRoundRect( - color = Colors.Green, + color = activeColor, topLeft = Offset(0f, trackY - trackHeight / 2), size = Size(knobX, trackHeight), cornerRadius = CornerRadius(cornerRadius), @@ -249,7 +255,7 @@ fun Slider( modifier = Modifier .size(KNOB_SIZE_DP.dp) .clip(CircleShape) - .background(Colors.Green) + .background(activeColor) ) { Box( modifier = Modifier @@ -267,6 +273,8 @@ fun Slider( StepSliderLabels( steps = steps, formatLabel = formatLabel, + stopTestTag = stopTestTag, + onStepClick = { onValueChange(steps[it]) }, ) }.first().measure(Constraints.fixedWidth(width)) @@ -303,17 +311,28 @@ private fun Modifier.stepSliderSemantics( private fun StepSliderLabels( steps: ImmutableList, formatLabel: (Int) -> String, + stopTestTag: ((index: Int) -> String)?, + onStepClick: (index: Int) -> Unit, modifier: Modifier = Modifier, ) { Layout( modifier = modifier, content = { - steps.forEach { step -> + steps.forEachIndexed { index, step -> Caption13Up( text = formatLabel(step), color = Colors.White64, textAlign = TextAlign.Center, - modifier = Modifier.width(KNOB_SIZE_DP.dp) + maxLines = 1, + modifier = Modifier + .widthIn(min = KNOB_SIZE_DP.dp) + .then( + stopTestTag?.let { tag -> + Modifier + .clickableAlpha { onStepClick(index) } + .testTag(tag(index)) + } ?: Modifier + ) ) } }, diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index 160b708161..d475362188 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -196,6 +196,7 @@ fun PaymentRequestsScreen( onDetails: (PaykitPaymentRequestId) -> Unit, showsNavigationBar: Boolean = true, topPadding: Dp = 0.dp, + autoPaidRequestIds: ImmutableSet = persistentSetOf(), ) { val pending by appViewModel.pendingPaymentRequests.collectAsStateWithLifecycle() val history by appViewModel.paymentRequestHistory.collectAsStateWithLifecycle() @@ -210,6 +211,7 @@ fun PaymentRequestsScreen( contacts = contacts.toImmutableList(), subscriptions = subscriptions.toImmutableList(), dismissingRequestIds = dismissingRequestIds.toImmutableSet(), + autoPaidRequestIds = autoPaidRequestIds, canRequestPayment = targets.isNotEmpty(), onBack = onBack, onRequestPayment = onRequestPayment, @@ -237,6 +239,7 @@ internal fun PaymentRequestsContent( onDetails: (PaykitPaymentRequestId) -> Unit, showsNavigationBar: Boolean = true, topPadding: Dp = 0.dp, + autoPaidRequestIds: ImmutableSet = persistentSetOf(), ) { val sections = paymentRequestSections(requests, pending, Clock.System.now()) val density = LocalDensity.current @@ -329,9 +332,12 @@ internal fun PaymentRequestsContent( PaymentRequestCard( request = request, contact = contacts.contactFor(request), - compactSubtitle = subscriptions.nameFor(request) - ?: request.note?.takeIf(String::isNotBlank) - ?: paymentRequestDate(request), + compactSubtitle = paymentRequestHistorySubtitle( + subtitle = subscriptions.nameFor(request) + ?: request.note?.takeIf(String::isNotBlank) + ?: paymentRequestDate(request), + isAutoPaid = request.id in autoPaidRequestIds, + ), showSignedAmount = true, onClick = { onDetails(request.id) }, ) @@ -466,6 +472,12 @@ private fun PaykitPaymentRequest.historyPeriod( } } +@Composable +private fun paymentRequestHistorySubtitle(subtitle: String, isAutoPaid: Boolean): String { + if (!isAutoPaid) return subtitle + return "$subtitle · ${stringResource(R.string.subscriptions__allowance_auto_paid)}" +} + @Composable internal fun paymentRequestDate(request: PaykitPaymentRequest): String = request.createdAt?.let { uiDateText(it.epochSeconds.toULong(), UiDateStyle.DATE) 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 new file mode 100644 index 0000000000..f8e48e3751 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt @@ -0,0 +1,588 @@ +package to.bitkit.ui.screens.subscriptions + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay +import to.bitkit.R +import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.PaykitAllowance +import to.bitkit.repositories.PaykitAllowanceLimits +import to.bitkit.ui.components.AllowanceRoute +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.MoneyCaptionB +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.components.PubkyContactRow +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.Slider +import to.bitkit.ui.components.SwipeToConfirm +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.AppViewModel +import kotlin.time.Duration.Companion.seconds + +/** Delay before a shown proposal counts as seen: another sheet's dismissal can close this one right after it opens. */ +private val PROPOSAL_SEEN_DELAY = 1.seconds + +private const val DEFAULT_PER_PAYMENT_INDEX = 1 +private const val DEFAULT_MONTHLY_INDEX = 2 + +@Composable +fun AllowanceSheet( + appViewModel: AppViewModel, + route: AllowanceRoute, + viewModel: AllowancesViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + if (!uiState.isLoaded) { + Box( + modifier = Modifier + .fillMaxWidth() + .sheetHeight() + .gradientBackground(startColor = Colors.Gray6, endColor = Colors.Black) + ) + return + } + + when (route) { + AllowanceRoute.Set -> AllowanceSetFlow( + uiState = uiState, + onSave = { contact, perPaymentUsd, monthlyUsd -> + viewModel.propose(contact, perPaymentUsd, monthlyUsd, onSuccess = appViewModel::hideSheet) + }, + ) + + is AllowanceRoute.Review -> { + val allowance = uiState.allowances.firstOrNull { it.id == route.entryId } + if (allowance == null) { + AllowanceUnavailableContent(onClose = appViewModel::hideSheet, modifier = Modifier.sheetHeight()) + } else { + LaunchedEffect(route.entryId) { + delay(PROPOSAL_SEEN_DELAY) + viewModel.markProposalPresented(route.entryId) + } + AllowanceReviewContent( + allowance = allowance, + isWorking = uiState.isWorking, + onClickDecline = { viewModel.decline(allowance.id, onSuccess = appViewModel::hideSheet) }, + onClickAccept = { viewModel.accept(allowance.id, onSuccess = appViewModel::hideSheet) }, + modifier = Modifier.sheetHeight() + ) + } + } + + is AllowanceRoute.Details -> { + val allowance = uiState.allowances.firstOrNull { it.id == route.entryId } + if (allowance == null) { + AllowanceUnavailableContent(onClose = appViewModel::hideSheet, modifier = Modifier.sheetHeight()) + } else { + AllowanceDetailContent( + allowance = allowance, + isWorking = uiState.isWorking, + onEnd = { viewModel.end(allowance.id, onSuccess = appViewModel::hideSheet) }, + modifier = Modifier.sheetHeight() + ) + } + } + } +} + +@Composable +private fun AllowanceSetFlow( + uiState: AllowancesUiState, + onSave: (contact: PubkyProfile, perPaymentUsd: Int, monthlyUsd: Int) -> Unit, +) { + var contactKey by rememberSaveable { mutableStateOf(null) } + val contact = uiState.contacts.firstOrNull { it.publicKey == contactKey } + + if (contact == null) { + AllowanceContactContent( + contacts = uiState.contacts, + onClickContact = { contactKey = it.publicKey }, + modifier = Modifier.sheetHeight() + ) + } else { + SetAllowanceContent( + contact = contact, + isWorking = uiState.isWorking, + onBack = { contactKey = null }, + onClickSave = { perPaymentUsd, monthlyUsd -> onSave(contact, perPaymentUsd, monthlyUsd) }, + modifier = Modifier.sheetHeight() + ) + } +} + +@Composable +private fun AllowanceContactContent( + contacts: ImmutableList, + onClickContact: (PubkyProfile) -> Unit, + modifier: Modifier = Modifier, +) { + AllowanceSheetColumn(modifier = modifier) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__allowance_choose_contact)) + if (contacts.isEmpty()) { + VerticalSpacer(16.dp) + BodyM( + text = stringResource(R.string.subscriptions__allowance_no_contacts), + color = Colors.White64, + ) + FillHeight() + } else { + LazyColumn(modifier = Modifier.weight(1f)) { + items(contacts, key = { it.publicKey }) { contact -> + PubkyContactRow( + profile = contact, + onClick = { onClickContact(contact) }, + modifier = Modifier.testTag("AllowanceContact-${contact.name}") + ) + HorizontalDivider() + } + } + } + } +} + +@Composable +private fun SetAllowanceContent( + contact: PubkyProfile, + isWorking: Boolean, + onBack: () -> Unit, + onClickSave: (perPaymentUsd: Int, monthlyUsd: Int) -> Unit, + modifier: Modifier = Modifier, +) { + val perPaymentStops = remember { PaykitAllowanceLimits.PER_PAYMENT_STOPS_USD.toImmutableList() } + val monthlyStops = remember { PaykitAllowanceLimits.MONTHLY_STOPS_USD.toImmutableList() } + var perPaymentUsd by rememberSaveable { mutableIntStateOf(perPaymentStops[DEFAULT_PER_PAYMENT_INDEX]) } + var monthlyUsd by rememberSaveable { mutableIntStateOf(monthlyStops[DEFAULT_MONTHLY_INDEX]) } + + AllowanceSheetColumn(modifier = modifier.testTag("SetAllowance")) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__allowance_set_title), onBack = onBack) + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + AllowanceCounterpartyCard(counterparty = contact) + VerticalSpacer(16.dp) + BodyM( + text = stringResource(R.string.subscriptions__allowance_set_explanation) + .replace("{name}", contact.name), + color = Colors.White64, + ) + VerticalSpacer(16.dp) + AllowanceLimitSlider( + title = stringResource(R.string.subscriptions__allowance_payment_limit), + value = perPaymentUsd, + stops = perPaymentStops, + onValueChange = { perPaymentUsd = it }, + testTag = "AllowancePerPayment", + ) + VerticalSpacer(16.dp) + HorizontalDivider() + AllowanceLimitSlider( + title = stringResource(R.string.subscriptions__allowance_monthly_allowance), + value = monthlyUsd, + stops = monthlyStops, + onValueChange = { monthlyUsd = it }, + testTag = "AllowanceMonthly", + ) + VerticalSpacer(16.dp) + HorizontalDivider() + VerticalSpacer(16.dp) + BodyS( + text = stringResource(R.string.subscriptions__allowance_set_summary) + .replace("{perPayment}", AllowanceAmountText.short(perPaymentUsd)) + .replace("{monthly}", AllowanceAmountText.short(monthlyUsd)), + color = Colors.White64, + modifier = Modifier.testTag("AllowanceSummary") + ) + VerticalSpacer(16.dp) + } + PrimaryButton( + text = stringResource(R.string.subscriptions__allowance_save), + onClick = { onClickSave(perPaymentUsd, monthlyUsd) }, + isLoading = isWorking, + modifier = Modifier.testTag("AllowanceSave") + ) + VerticalSpacer(16.dp) + } +} + +@Composable +private fun AllowanceLimitSlider( + title: String, + value: Int, + stops: ImmutableList, + onValueChange: (Int) -> Unit, + testTag: String, +) { + Caption13Up( + text = title, + color = Colors.White64, + modifier = Modifier.padding(vertical = 16.dp) + ) + Slider( + value = value, + steps = stops, + onValueChange = onValueChange, + formatLabel = AllowanceAmountText::short, + activeColor = Colors.Purple, + trackColor = Colors.Purple32, + stopTestTag = { "${testTag}Stop-$it" }, + modifier = Modifier.testTag(testTag) + ) +} + +@Composable +private fun AllowanceReviewContent( + allowance: AllowanceUi, + isWorking: Boolean, + onClickDecline: () -> Unit, + onClickAccept: () -> Unit, + modifier: Modifier = Modifier, +) { + AllowanceSheetColumn(modifier = modifier.testTag("AllowanceReview")) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__allowance_title)) + VerticalSpacer(16.dp) + Display(text = allowance.reviewHeadline.withAccent(accentColor = Colors.Purple)) + VerticalSpacer(16.dp) + AllowanceCounterpartyCard(counterparty = allowance.counterparty, isCard = true) + VerticalSpacer(24.dp) + AllowanceLimitsGrid(allowance = allowance) + VerticalSpacer(24.dp) + BodyM(text = allowance.reviewExplanation, color = Colors.White64) + FillHeight() + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + SecondaryButton( + text = stringResource(R.string.subscriptions__allowance_decline), + onClick = onClickDecline, + enabled = !isWorking, + modifier = Modifier + .weight(1f) + .testTag("AllowanceDecline") + ) + PrimaryButton( + text = stringResource(R.string.subscriptions__allowance_accept), + onClick = onClickAccept, + isLoading = isWorking, + modifier = Modifier + .weight(1f) + .testTag("AllowanceAccept") + ) + } + VerticalSpacer(16.dp) + } +} + +@Composable +private fun AllowanceDetailContent( + allowance: AllowanceUi, + isWorking: Boolean, + onEnd: () -> Unit, + modifier: Modifier = Modifier, +) { + AllowanceSheetColumn(modifier = modifier.testTag("AllowanceDetail")) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__allowance_title)) + AllowanceCounterpartyCard(counterparty = allowance.counterparty, isCard = true) + VerticalSpacer(24.dp) + AllowanceLimitsGrid(allowance = allowance) + VerticalSpacer(24.dp) + Caption13Up(text = stringResource(R.string.subscriptions__allowance_paid_so_far), color = Colors.White64) + VerticalSpacer(8.dp) + BodySSB(text = allowance.paidSoFar, modifier = Modifier.testTag("AllowancePaidSoFar")) + VerticalSpacer(24.dp) + BodyM( + text = allowance.explanation, + color = Colors.White64, + modifier = Modifier.testTag("AllowanceDetailStatus") + ) + FillHeight() + if (allowance.canEnd) { + SwipeToConfirm( + text = stringResource( + if (allowance.isProposal) { + R.string.subscriptions__allowance_swipe_withdraw + } else { + R.string.subscriptions__allowance_swipe_end + } + ), + color = Colors.Purple, + loading = isWorking, + onConfirm = onEnd, + ) + VerticalSpacer(16.dp) + } + } +} + +@Composable +private fun AllowanceUnavailableContent( + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + AllowanceSheetColumn(modifier = modifier) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__allowance_title)) + FillHeight() + BodyM(text = stringResource(R.string.subscriptions__allowance_error_unavailable), color = Colors.White64) + FillHeight() + PrimaryButton(text = stringResource(R.string.common__close), onClick = onClose) + VerticalSpacer(16.dp) + } +} + +@Composable +private fun AllowanceSheetColumn( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground(startColor = Colors.Gray6, endColor = Colors.Black) + .navigationBarsPadding() + .padding(horizontal = 16.dp), + content = content, + ) +} + +@Composable +private fun AllowanceCounterpartyCard( + counterparty: PubkyProfile, + isCard: Boolean = false, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .then( + if (isCard) { + Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Colors.Gray6) + .padding(16.dp) + } else { + Modifier + } + ) + .testTag("AllowanceCounterparty") + ) { + PubkyContactAvatar(profile = counterparty, size = 48.dp) + Column( + modifier = Modifier + .padding(start = 16.dp) + .weight(1f) + ) { + Caption13Up( + text = counterparty.truncatedPublicKey, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BodyMSB( + text = counterparty.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun AllowanceLimitsGrid(allowance: AllowanceUi) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth()) { + AllowanceLimitCell( + title = stringResource(R.string.subscriptions__allowance_per_payment), + upTo = allowance.perPaymentUpTo, + sats = allowance.perPaymentSats, + testTag = "AllowancePerPaymentValue", + modifier = Modifier.weight(1f) + ) + AllowanceLimitCell( + title = stringResource(R.string.subscriptions__allowance_each_month), + upTo = allowance.monthlyUpTo, + sats = allowance.monthlySats, + testTag = "AllowanceMonthlyValue", + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun AllowanceLimitCell( + title: String, + upTo: String, + sats: Long?, + testTag: String, + modifier: Modifier = Modifier, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier) { + Caption13Up(text = title, color = Colors.White64) + BodySSB(text = upTo, modifier = Modifier.testTag(testTag)) + sats?.let { MoneyCaptionB(sats = it, color = Colors.White64, symbol = true) } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + AllowanceContactContent( + contacts = persistentListOf( + previewAllowance(name = "Leo").counterparty, + PubkyProfile.forDisplay( + publicKey = "pubkyqzx4bxnsmp6n1u3y3uyt6hbjmaqwzs5tbaxkh7wwcbzjb8sccq3o", + name = "Mia", + imageUrl = null, + ), + ), + onClickContact = {}, + modifier = Modifier.sheetHeight() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview2() { + AppThemeSurface { + BottomSheetPreview { + AllowanceContactContent( + contacts = persistentListOf(), + onClickContact = {}, + modifier = Modifier.sheetHeight() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview3() { + AppThemeSurface { + BottomSheetPreview { + SetAllowanceContent( + contact = previewAllowance(name = "Leo").counterparty, + isWorking = false, + onBack = {}, + onClickSave = { _, _ -> }, + modifier = Modifier.sheetHeight() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview4() { + AppThemeSurface { + BottomSheetPreview { + AllowanceReviewContent( + allowance = previewAllowance(name = "Ana").copy( + status = PaykitAllowance.Status.AWAITING_MY_ANSWER, + subtitle = "Waiting for your answer", + isAnswerable = true, + isProposal = true, + ), + isWorking = false, + onClickDecline = {}, + onClickAccept = {}, + modifier = Modifier.sheetHeight() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview5() { + AppThemeSurface { + BottomSheetPreview { + AllowanceDetailContent( + allowance = previewAllowance(), + isWorking = false, + onEnd = {}, + modifier = Modifier.sheetHeight() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview6() { + AppThemeSurface { + BottomSheetPreview { + AllowanceDetailContent( + allowance = previewAllowance().copy( + status = PaykitAllowance.Status.ENDED, + subtitle = "Ended · $2.00 paid automatically", + explanation = "This allowance has ended. Every request asks again.", + canEnd = false, + ), + isWorking = false, + onEnd = {}, + modifier = Modifier.sheetHeight() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview7() { + AppThemeSurface { + BottomSheetPreview { + AllowanceUnavailableContent(onClose = {}, modifier = Modifier.sheetHeight()) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesScreen.kt new file mode 100644 index 0000000000..824b95c1f2 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesScreen.kt @@ -0,0 +1,285 @@ +package to.bitkit.ui.screens.subscriptions + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.collections.immutable.persistentListOf +import to.bitkit.R +import to.bitkit.models.PubkyProfile +import to.bitkit.models.USD_SYMBOL +import to.bitkit.repositories.PaykitAllowance +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.HorizontalSpacer +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent + +/** Row opacity for an allowance that no longer pays: ended, declined, expired or in conflict. */ +private const val INACTIVE_ROW_ALPHA = 0.64f + +/** The Allowances tab on the Subscriptions screen: the empty state or the allowance list, plus Add Allowance. */ +@Composable +fun AllowancesScreen( + topPadding: Dp, + onClickAdd: () -> Unit, + onClickAllowance: (AllowanceUi) -> Unit, + modifier: Modifier = Modifier, + viewModel: AllowancesViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { viewModel.refresh() } + + AllowancesContent( + uiState = uiState, + topPadding = topPadding, + onClickAdd = onClickAdd, + onClickAllowance = onClickAllowance, + modifier = modifier + ) +} + +@Composable +private fun AllowancesContent( + uiState: AllowancesUiState, + topPadding: Dp, + onClickAdd: () -> Unit, + onClickAllowance: (AllowanceUi) -> Unit, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + var footerHeight by remember { mutableStateOf(0.dp) } + + Box(modifier = modifier.fillMaxSize()) { + when { + !uiState.isLoaded -> Unit + uiState.allowances.isEmpty() -> AllowancesEmptyState( + modifier = Modifier + .fillMaxSize() + .padding(top = topPadding, bottom = footerHeight) + ) + else -> LazyColumn( + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = topPadding + 32.dp, + bottom = footerHeight + 16.dp, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxSize() + ) { + items(uiState.allowances, key = { it.id }) { allowance -> + AllowanceRow( + allowance = allowance, + onClick = { onClickAllowance(allowance) }, + ) + } + } + } + + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .onSizeChanged { footerHeight = with(density) { it.height.toDp() } } + .navigationBarsPadding() + ) { + VerticalSpacer(16.dp) + PrimaryButton( + text = stringResource(R.string.subscriptions__allowance_add), + onClick = onClickAdd, + modifier = Modifier + .padding(horizontal = 16.dp) + .testTag("AllowanceAdd") + ) + VerticalSpacer(16.dp) + } + } +} + +@Composable +private fun AllowancesEmptyState(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 32.dp) + .testTag("AllowancesEmpty") + ) { + FillHeight() + Image( + painter = painterResource(R.drawable.group), + contentDescription = null, + modifier = Modifier + .size(256.dp) + .align(Alignment.CenterHorizontally) + ) + VerticalSpacer(32.dp) + Display( + text = stringResource(R.string.subscriptions__allowances_empty_headline) + .withAccent(accentColor = Colors.Purple), + ) + VerticalSpacer(8.dp) + BodyM(text = stringResource(R.string.subscriptions__allowances_empty_description), color = Colors.White64) + } +} + +@Composable +private fun AllowanceRow( + allowance: AllowanceUi, + onClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .alpha(if (allowance.isInactive) INACTIVE_ROW_ALPHA else 1f) + .clip(RoundedCornerShape(16.dp)) + .background(Colors.Gray6) + .clickableAlpha(onClick = onClick) + .padding(16.dp) + .testTag("AllowanceRow-${allowance.id}") + ) { + PubkyContactAvatar(profile = allowance.counterparty, size = 40.dp) + Column( + modifier = Modifier + .padding(start = 16.dp) + .weight(1f) + ) { + BodyMSB( + text = allowance.counterparty.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + CaptionB( + text = allowance.subtitle, + color = Colors.White64, + maxLines = 1, + modifier = Modifier.testTag("AllowanceRowStatus") + ) + } + HorizontalSpacer(8.dp) + Column(horizontalAlignment = Alignment.End) { + AllowanceUsd(amount = allowance.monthlyAmount) + CaptionB( + text = stringResource(R.string.subscriptions__allowance_monthly_limit), + color = Colors.White64, + maxLines = 1, + ) + } + } +} + +/** A dollar amount with a dimmed currency symbol, as the allowance rows show limits. */ +@Composable +internal fun AllowanceUsd(amount: String, modifier: Modifier = Modifier) { + BodyMSB( + text = "$USD_SYMBOL $amount".withAccent(accentColor = Colors.White64), + modifier = modifier + ) +} + +internal fun previewAllowance(id: String = "allowance-1", name: String = "Leo") = AllowanceUi( + id = id, + counterparty = PubkyProfile.forDisplay( + publicKey = "pubky8pinxcsrt5ufsyu3n1pzkq5e3bdi7gbq67pcu7tsnjj65ntx1xy", + name = name, + imageUrl = null, + ), + status = PaykitAllowance.Status.ACTIVE, + subtitle = "Active · $5 a payment", + explanation = "Requests within these limits are paid automatically. Anything above them asks you first.", + reviewHeadline = "Ana offers\nan allowance", + reviewExplanation = "Ana's wallet will pay your requests within these limits without asking each time. " + + "Either of you can end it.", + monthlyAmount = "50.00", + perPaymentUpTo = "Up to $5.00", + monthlyUpTo = "Up to $50.00", + perPaymentSats = 4_512L, + monthlySats = 45_120L, + paidSoFar = "$2.00", + isAnswerable = false, + canEnd = true, + isProposal = false, +) + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + AllowancesContent( + uiState = AllowancesUiState(isLoaded = true), + topPadding = 0.dp, + onClickAdd = {}, + onClickAllowance = {}, + ) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview2() { + AppThemeSurface { + AllowancesContent( + uiState = AllowancesUiState( + isLoaded = true, + allowances = persistentListOf( + previewAllowance(), + previewAllowance(id = "allowance-2", name = "Mia").copy( + status = PaykitAllowance.Status.AWAITING_ANSWER, + subtitle = "Waiting for an answer", + isProposal = true, + ), + previewAllowance(id = "allowance-3", name = "John Carvalho").copy( + status = PaykitAllowance.Status.ENDED, + subtitle = "Ended · $2.00 paid automatically", + canEnd = false, + ), + ), + ), + topPadding = 0.dp, + onClickAdd = {}, + onClickAllowance = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModel.kt new file mode 100644 index 0000000000..9bd02e469b --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModel.kt @@ -0,0 +1,348 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.ui.screens.subscriptions + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.synonym.paykit.AllowanceLifecycleState +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.Toast +import to.bitkit.models.USD +import to.bitkit.models.USD_SYMBOL +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.PaykitAllowance +import to.bitkit.repositories.PaykitAllowanceEntry +import to.bitkit.repositories.PaykitAllowanceError +import to.bitkit.repositories.PaykitAllowanceLimits +import to.bitkit.repositories.PaykitAllowanceRepo +import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitPaymentRequestRepo +import to.bitkit.repositories.PubkyRepo +import to.bitkit.ui.shared.toast.ToastEventBus +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.util.Locale +import javax.inject.Inject +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@HiltViewModel +class AllowancesViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val allowanceRepo: PaykitAllowanceRepo, + private val paymentRequestRepo: PaykitPaymentRequestRepo, + private val pubkyRepo: PubkyRepo, + private val currencyRepo: CurrencyRepo, + private val clock: Clock, +) : ViewModel() { + companion object { + /** How often time-based statuses (not active yet, expired) are re-evaluated while the UI is visible. */ + private val STATUS_REFRESH_INTERVAL = 1.minutes + + /** Keeps the state alive across short configuration changes. */ + private const val STOP_TIMEOUT_MS = 5_000L + } + + private val isWorking = MutableStateFlow(false) + + private val now: Flow = flow { + while (true) { + emit(clock.now()) + delay(STATUS_REFRESH_INTERVAL) + } + } + + private val allowances: Flow> = combine( + allowanceRepo.entries, + allowanceRepo.autoPaidSats, + pubkyRepo.contacts, + currencyRepo.currencyState, + now, + ) { entries, autoPaidSats, contacts, _, now -> + entries.map { it.toUi(contacts, autoPaidSats[it.id] ?: 0uL, now) }.toImmutableList() + } + + private val contactChoices: Flow> = combine( + pubkyRepo.contacts, + paymentRequestRepo.eligibleTargets, + ) { contacts, targets -> + contacts.filter { contact -> + targets.any { PubkyPublicKeyFormat.matches(it.publicKey, contact.publicKey) } + }.toImmutableList() + } + + // A fresh subscriber starts from the unloaded state, so a sheet never renders a stale entry list. + val uiState: StateFlow = combine( + allowances, + contactChoices, + isWorking, + ) { allowances, contacts, isWorking -> + AllowancesUiState( + isLoaded = true, + allowances = allowances, + contacts = contacts, + isWorking = isWorking, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS, replayExpirationMillis = 0), + initialValue = AllowancesUiState(), + ) + + /** Payment request ids paid by an allowance, for the "Auto-paid" suffix in payment history. */ + val autoPaidRequestIds: StateFlow> = combine( + paymentRequestRepo.paymentRequestHistory, + allowanceRepo.autoPaidSats, + ) { history, _ -> + history.map { it.id }.filter(allowanceRepo::isAutoPaid).toImmutableSet() + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), persistentSetOf()) + + fun refresh() { + viewModelScope.launch { allowanceRepo.refresh() } + } + + fun markProposalPresented(entryId: String) { + viewModelScope.launch { allowanceRepo.markProposalPresented(entryId) } + } + + fun propose(contact: PubkyProfile, perPaymentUsd: Int, monthlyUsd: Int, onSuccess: () -> Unit) { + val limits = limitsInSats(perPaymentUsd, monthlyUsd) + if (limits == null) { + viewModelScope.launch { toastError(context.getString(R.string.subscriptions__allowance_error_unavailable)) } + return + } + runAction(onSuccess) { allowanceRepo.propose(contact.publicKey, limits) } + } + + fun accept(entryId: String, onSuccess: () -> Unit) = runAction(onSuccess) { allowanceRepo.accept(entryId) } + + fun decline(entryId: String, onSuccess: () -> Unit) = runAction(onSuccess) { allowanceRepo.reject(entryId) } + + fun end(entryId: String, onSuccess: () -> Unit) = runAction(onSuccess) { allowanceRepo.end(entryId) } + + private fun runAction(onSuccess: () -> Unit, action: suspend () -> Result) { + if (isWorking.value) return + isWorking.update { true } + viewModelScope.launch { + action() + .onSuccess { onSuccess() } + .onFailure { toastError(errorDescription(it)) } + isWorking.update { false } + } + } + + private fun errorDescription(error: Throwable): String = when (error) { + PaykitAllowanceError.ContactNotLinked -> context.getString(R.string.subscriptions__allowance_error_not_linked) + PaykitAllowanceError.Unavailable -> context.getString(R.string.subscriptions__allowance_error_unavailable) + else -> error.message?.takeIf(String::isNotBlank) ?: context.getString(R.string.common__error_body) + } + + private suspend fun toastError(description: String) = ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = description, + ) + + private fun limitsInSats(perPaymentUsd: Int, monthlyUsd: Int): PaykitAllowanceLimits? { + val perPaymentSats = currencyRepo.convertFiatToSats(BigDecimal(perPaymentUsd), USD).getOrNull() ?: return null + val monthlySats = currencyRepo.convertFiatToSats(BigDecimal(monthlyUsd), USD).getOrNull() ?: return null + return PaykitAllowanceLimits( + perPaymentUsd = perPaymentUsd, + monthlyUsd = monthlyUsd, + perPaymentSats = perPaymentSats, + monthlySats = monthlySats, + ) + } + + private fun PaykitAllowanceEntry.toUi(contacts: List, paidSats: ULong, now: Instant): AllowanceUi { + val profile = contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, counterparty) } + ?: PubkyProfile.placeholder(counterparty) + val status = status(now) + val isAllowee = role == PaykitAllowance.Role.ALLOWEE + return AllowanceUi( + id = id, + counterparty = profile, + status = status, + subtitle = subtitle(status, paidSats), + explanation = explanation(status), + reviewHeadline = context.getString( + if (isAllowee) { + R.string.subscriptions__allowance_offer_headline + } else { + R.string.subscriptions__allowance_request_headline + } + ).replace("{name}", profile.name), + reviewExplanation = context.getString( + if (isAllowee) { + R.string.subscriptions__allowance_offer_explanation + } else { + R.string.subscriptions__allowance_request_explanation + } + ).replace("{name}", profile.name), + monthlyAmount = limitAmount(limits?.monthlyUsd, monthlyLimitSats), + perPaymentUpTo = upTo(limits?.perPaymentUsd, perPaymentMaxSats), + monthlyUpTo = upTo(limits?.monthlyUsd, monthlyLimitSats), + perPaymentSats = perPaymentMaxSats?.toDisplaySats(), + monthlySats = monthlyLimitSats?.toDisplaySats(), + paidSoFar = USD_SYMBOL + fiatValue(paidSats), + isAnswerable = primary.isAnswerable, + canEnd = canEnd, + isProposal = primary.lifecycleState == AllowanceLifecycleState.PROPOSED, + ) + } + + private fun PaykitAllowanceEntry.subtitle(status: PaykitAllowance.Status, paidSats: ULong): String = when (status) { + PaykitAllowance.Status.ACTIVE -> listOfNotNull( + context.getString(R.string.subscriptions__allowance_status_active), + perPaymentShort(), + ).joinToString(" · ") + PaykitAllowance.Status.ENDED -> { + val ended = context.getString(R.string.subscriptions__allowance_status_ended) + if (paidSats == 0uL) { + ended + } else { + val paid = context.getString(R.string.subscriptions__allowance_paid_automatically) + .replace("{amount}", USD_SYMBOL + fiatValue(paidSats)) + "$ended · $paid" + } + } + else -> statusText(status) + } + + private fun PaykitAllowanceEntry.explanation(status: PaykitAllowance.Status): String = when (status) { + PaykitAllowance.Status.ACTIVE -> context.getString( + if (role == PaykitAllowance.Role.ALLOWER) { + R.string.subscriptions__allowance_detail_active_allower + } else { + R.string.subscriptions__allowance_detail_active_allowee + } + ) + PaykitAllowance.Status.ENDED -> context.getString(R.string.subscriptions__allowance_detail_ended) + else -> statusText(status) + } + + private fun statusText(status: PaykitAllowance.Status): String = context.getString( + when (status) { + PaykitAllowance.Status.ACTIVE -> R.string.subscriptions__allowance_status_active + PaykitAllowance.Status.AWAITING_ANSWER -> R.string.subscriptions__allowance_status_waiting + PaykitAllowance.Status.AWAITING_MY_ANSWER -> R.string.subscriptions__allowance_status_needs_answer + PaykitAllowance.Status.NOT_YET_ACTIVE -> R.string.subscriptions__allowance_status_scheduled + PaykitAllowance.Status.EXPIRED -> R.string.subscriptions__allowance_status_expired + PaykitAllowance.Status.DECLINED -> R.string.subscriptions__allowance_status_declined + PaykitAllowance.Status.ENDED -> R.string.subscriptions__allowance_status_ended + PaykitAllowance.Status.CONFLICTED -> R.string.subscriptions__allowance_status_conflicted + } + ) + + private fun PaykitAllowanceEntry.perPaymentShort(): String? { + val amount = limits?.perPaymentUsd?.let(AllowanceAmountText::short) + ?: perPaymentMaxSats?.let { USD_SYMBOL + limitValue(it) } + ?: return null + return context.getString(R.string.subscriptions__allowance_per_payment_short).replace("{amount}", amount) + } + + private fun upTo(usd: Int?, sats: ULong?): String { + val amount = usd?.let { USD_SYMBOL + AllowanceAmountText.formatted(BigDecimal(it)) } + ?: sats?.let { USD_SYMBOL + limitValue(it) } + ?: AllowanceAmountText.UNKNOWN + return context.getString(R.string.subscriptions__allowance_up_to).replace("{amount}", amount) + } + + private fun limitAmount(usd: Int?, sats: ULong?): String = + usd?.let { AllowanceAmountText.formatted(BigDecimal(it)) } + ?: sats?.let(::limitValue) + ?: AllowanceAmountText.UNKNOWN + + private fun fiatValue(sats: ULong): String = usdValue(sats)?.let(AllowanceAmountText::formatted) + ?: AllowanceAmountText.UNKNOWN + + private fun limitValue(sats: ULong): String = usdValue(sats)?.let(AllowanceAmountText::limit) + ?: AllowanceAmountText.UNKNOWN + + private fun usdValue(sats: ULong): BigDecimal? = + currencyRepo.convertSatsToFiat(sats.toDisplaySats(), USD).getOrNull()?.value +} + +@Stable +data class AllowancesUiState( + val isLoaded: Boolean = false, + val allowances: ImmutableList = persistentListOf(), + val contacts: ImmutableList = persistentListOf(), + val isWorking: Boolean = false, +) + +/** One allowance entry as the UI shows it, with every amount already converted to US dollars. */ +@Stable +data class AllowanceUi( + val id: String, + val counterparty: PubkyProfile, + val status: PaykitAllowance.Status, + val subtitle: String, + val explanation: String, + val reviewHeadline: String, + val reviewExplanation: String, + val monthlyAmount: String, + val perPaymentUpTo: String, + val monthlyUpTo: String, + val perPaymentSats: Long?, + val monthlySats: Long?, + val paidSoFar: String, + val isAnswerable: Boolean, + val canEnd: Boolean, + val isProposal: Boolean, +) { + val isInactive: Boolean + get() = when (status) { + PaykitAllowance.Status.ENDED, + PaykitAllowance.Status.DECLINED, + PaykitAllowance.Status.EXPIRED, + PaykitAllowance.Status.CONFLICTED, + -> true + else -> false + } +} + +/** US dollar amounts as iOS shows them: grouped, en_US, with limits set in whole dollars rounded back to the dollar. */ +internal object AllowanceAmountText { + /** Shown when an amount cannot be converted, e.g. before exchange rates load. */ + const val UNKNOWN = "—" + + fun formatted(usd: BigDecimal): String = DecimalFormat("#,##0.00", DecimalFormatSymbols(Locale.US)).format(usd) + + fun short(usd: Int): String = USD_SYMBOL + DecimalFormat("#,##0", DecimalFormatSymbols(Locale.US)).format(usd) + + /** + * A limit set in whole dollars on the other wallet, shown back from its BTC terms at today's rate: + * rounded to the dollar so a small rate move does not turn $5 into $4.99. + */ + fun limit(usd: BigDecimal): String = + formatted(if (usd >= BigDecimal.ONE) usd.setScale(0, RoundingMode.HALF_UP) else usd) +} + +private fun ULong.toDisplaySats(): Long = coerceAtMost(Long.MAX_VALUE.toULong()).toLong() diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt index c1b03d96d5..a1f5dc7764 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieCompositionSpec @@ -71,6 +72,7 @@ import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitRecurrenceUnit import to.bitkit.repositories.PaykitSubscription import to.bitkit.repositories.PaykitSubscriptionId +import to.bitkit.ui.components.AllowanceRoute import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BodyS @@ -119,6 +121,7 @@ fun SubscriptionsScreen( onDetails: (PaykitSubscriptionId) -> Unit, onPaymentRequestDetails: (PaykitPaymentRequestId) -> Unit, showPayments: Boolean = false, + allowancesViewModel: AllowancesViewModel = hiltViewModel(), ) { val subscriptions by appViewModel.subscriptions.collectAsStateWithLifecycle() val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() @@ -142,6 +145,7 @@ fun SubscriptionsScreen( }, onCreateSubscription = onCreateSubscription, paymentsContent = { topPadding -> + val autoPaidRequestIds by allowancesViewModel.autoPaidRequestIds.collectAsStateWithLifecycle() PaymentRequestsScreen( appViewModel = appViewModel, onBack = onBack, @@ -149,6 +153,18 @@ fun SubscriptionsScreen( onDetails = onPaymentRequestDetails, showsNavigationBar = false, topPadding = topPadding, + autoPaidRequestIds = autoPaidRequestIds, + ) + }, + allowancesContent = { topPadding -> + AllowancesScreen( + topPadding = topPadding, + onClickAdd = { appViewModel.showSheet(Sheet.Allowance(AllowanceRoute.Set)) }, + onClickAllowance = { + val route = if (it.isAnswerable) AllowanceRoute.Review(it.id) else AllowanceRoute.Details(it.id) + appViewModel.showSheet(Sheet.Allowance(route)) + }, + viewModel = allowancesViewModel, ) }, ) @@ -166,6 +182,7 @@ internal fun SubscriptionsContent( onSubscription: (PaykitSubscription) -> Unit, onCreateSubscription: () -> Unit, paymentsContent: @Composable (topPadding: Dp) -> Unit, + allowancesContent: @Composable (topPadding: Dp) -> Unit = {}, ) { val proposals = subscriptions.filter { it.isPayer && it.isProposalVisible(now) } val active = subscriptions.filter { it.isPayer && it.isActive(now) } @@ -194,6 +211,8 @@ internal fun SubscriptionsContent( ) { if (selectedTab == SubscriptionTab.Payments) { paymentsContent(headerHeight) + } else if (selectedTab == SubscriptionTab.Allowances) { + allowancesContent(headerHeight) } else if (!hasVisibleSubscriptions) { SubscriptionEmptyState( Modifier @@ -321,7 +340,7 @@ private fun SubscriptionTabs( onTabChange: (SubscriptionTab) -> Unit, ) { CustomTabRowWithSpacing( - tabs = persistentListOf(SubscriptionTab.Overview, SubscriptionTab.Payments), + tabs = persistentListOf(SubscriptionTab.Overview, SubscriptionTab.Allowances, SubscriptionTab.Payments), currentTabIndex = selectedTab.ordinal, selectedColor = Colors.White, onTabChange = onTabChange, @@ -332,12 +351,14 @@ private fun SubscriptionTabs( internal enum class SubscriptionTab : TabItem { Overview, + Allowances, Payments; override val uiText: String @Composable get() = stringResource( when (this) { Overview -> R.string.subscriptions__overview + Allowances -> R.string.subscriptions__allowances Payments -> R.string.subscriptions__payments } ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4c570f91c9..fb32ae3cb7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1076,6 +1076,53 @@ Profile Create Profile Active + Accept + Add Allowance + Auto-paid + Choose Contact + Decline + Your requests within these limits are paid automatically. + Requests within these limits are paid automatically. Anything above them asks you first. + This allowance has ended. Every request asks again. + Each month + This contact is not connected yet. Try again in a moment. + This allowance is not available right now. + Auto-pay sent {amount} to {name} + Payment Executed + A {amount} request from {name} is above your allowance. Review it to pay. + Limit Reached + Monthly allowance + Monthly limit + Add a contact first. Allowances cover payment requests from your contacts. + {name}\'s wallet will pay your requests within these limits without asking each time. Either of you can end it. + an allowance]]> + {amount} paid automatically + Paid automatically + This request is already being paid. + Payment limit + Per payment + {amount} a payment + Your wallet will pay {name}\'s requests within these limits without asking you each time. Either of you can end it. + an allowance]]> + Save Allowance + Automatically approve payment requests from {name} below these limits: + Requests up to {perPayment} will be paid automatically, up to {monthly} a month, without asking you each time. + Set Allowance + Active + Needs attention + Declined + Ended + Expired + Waiting for your answer + Not active yet + Waiting for an answer + Swipe To End Allowance + Swipe To Withdraw + Allowance + Up to {amount} + Allowances + You can set spending limits to automatically fulfill payment requests from trusted peers. + allowances]]> Cancel Cancel Subscription Choose Recipient diff --git a/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt new file mode 100644 index 0000000000..7b44377a1f --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt @@ -0,0 +1,230 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.ui.screens.subscriptions + +import android.content.Context +import com.synonym.paykit.AllowanceLifecycleState +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.R +import to.bitkit.models.ConvertedAmount +import to.bitkit.models.PubkyProfile +import to.bitkit.models.USD +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.CurrencyState +import to.bitkit.repositories.PaykitAllowance +import to.bitkit.repositories.PaykitAllowanceEntry +import to.bitkit.repositories.PaykitAllowanceError +import to.bitkit.repositories.PaykitAllowanceLimits +import to.bitkit.repositories.PaykitAllowanceRepo +import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestRepo +import to.bitkit.repositories.PaykitPaymentRequestTarget +import to.bitkit.repositories.PubkyRepo +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.ServiceError +import java.math.BigDecimal +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +class AllowancesViewModelTest : BaseUnitTest() { + companion object { + private const val LEO_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val MIA_KEY = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + + /** Sats per US dollar in these tests, so 4 990 sats is $4.99. */ + private val SATS_PER_USD = BigDecimal(1_000) + } + + private val context: Context = mock() + private val allowanceRepo: PaykitAllowanceRepo = mock() + private val paymentRequestRepo: PaykitPaymentRequestRepo = mock() + private val pubkyRepo: PubkyRepo = mock() + private val currencyRepo: CurrencyRepo = mock() + private val clock = object : Clock { + override fun now() = Instant.parse("2027-01-15T08:00:00Z") + } + + private val entries = MutableStateFlow>(emptyList()) + private val autoPaidSats = MutableStateFlow>(emptyMap()) + private val contacts = MutableStateFlow(listOf(profile(LEO_KEY, "Leo"), profile(MIA_KEY, "Mia"))) + private val targets = MutableStateFlow>(emptyList()) + + private lateinit var sut: AllowancesViewModel + + @Before + fun setUp() { + whenever(allowanceRepo.entries).thenReturn(entries) + whenever(allowanceRepo.autoPaidSats).thenReturn(autoPaidSats) + whenever(pubkyRepo.contacts).thenReturn(contacts) + whenever(paymentRequestRepo.eligibleTargets).thenReturn(targets) + whenever(paymentRequestRepo.paymentRequestHistory) + .thenReturn(MutableStateFlow(emptyList())) + whenever(currencyRepo.currencyState).thenReturn(MutableStateFlow(CurrencyState())) + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { + val sats = it.getArgument(0) + ConvertedAmount( + value = BigDecimal(sats).divide(SATS_PER_USD), + formatted = "", + symbol = "$", + currency = USD, + flag = "", + sats = sats, + ) + } + whenever(currencyRepo.convertFiatToSats(any(), anyOrNull())).thenAnswer { + it.getArgument(0).multiply(SATS_PER_USD).toLong().toULong() + } + whenever(context.getString(any())).thenReturn("") + mapOf( + R.string.subscriptions__allowance_status_active to "Active", + R.string.subscriptions__allowance_status_ended to "Ended", + R.string.subscriptions__allowance_status_waiting to "Waiting for an answer", + R.string.subscriptions__allowance_per_payment_short to "{amount} a payment", + R.string.subscriptions__allowance_paid_automatically to "{amount} paid automatically", + R.string.subscriptions__allowance_up_to to "Up to {amount}", + R.string.subscriptions__allowance_error_not_linked to "This contact is not connected yet.", + R.string.common__error to "Error", + ).forEach { (id, text) -> whenever(context.getString(id)).thenReturn(text) } + sut = AllowancesViewModel( + context = context, + allowanceRepo = allowanceRepo, + paymentRequestRepo = paymentRequestRepo, + pubkyRepo = pubkyRepo, + currencyRepo = currencyRepo, + clock = clock, + ) + } + + @Test + fun `limits known only in sats round back to whole dollars`() = test { + entries.value = listOf(entry(perPaymentSats = 4_990uL, monthlySats = 49_900uL, limits = null)) + + val allowance = loadedState().allowances.single() + + assertEquals("Active · $5.00 a payment", allowance.subtitle) + assertEquals("Up to $5.00", allowance.perPaymentUpTo) + assertEquals("50.00", allowance.monthlyAmount) + } + + @Test + fun `limits picked on this wallet show the chosen dollar stops`() = test { + val limits = PaykitAllowanceLimits( + perPaymentUsd = 5, + monthlyUsd = 50, + perPaymentSats = 4_900uL, + monthlySats = 49_000uL, + ) + entries.value = listOf(entry(perPaymentSats = 4_900uL, monthlySats = 49_000uL, limits = limits)) + + val allowance = loadedState().allowances.single() + + assertEquals("Active · $5 a payment", allowance.subtitle) + assertEquals("50.00", allowance.monthlyAmount) + } + + @Test + fun `an ended allowance shows what it paid automatically`() = test { + entries.value = listOf(entry(state = AllowanceLifecycleState.ENDED)) + autoPaidSats.value = mapOf("entry-1" to 2_000uL) + + val allowance = loadedState().allowances.single() + + assertEquals("Ended · $2.00 paid automatically", allowance.subtitle) + assertEquals("$2.00", allowance.paidSoFar) + assertTrue(allowance.isInactive) + } + + @Test + fun `contact picker lists only contacts that can receive payment requests`() = test { + targets.value = listOf(PaykitPaymentRequestTarget(LEO_KEY, "bitkit/wallet")) + + assertEquals(listOf("Leo"), loadedState().contacts.map { it.name }) + } + + @Test + fun `saving converts the chosen dollar stops to sats once`() = test { + val limits = PaykitAllowanceLimits( + perPaymentUsd = 5, + monthlyUsd = 100, + perPaymentSats = 5_000uL, + monthlySats = 100_000uL, + ) + whenever(allowanceRepo.propose(LEO_KEY, limits)).thenReturn(Result.success(Unit)) + var saved = false + + sut.propose(profile(LEO_KEY, "Leo"), perPaymentUsd = 5, monthlyUsd = 100) { saved = true } + + verify(allowanceRepo).propose(LEO_KEY, limits) + assertTrue(saved) + } + + @Test + fun `saving without a dollar rate proposes nothing`() = test { + whenever(currencyRepo.convertFiatToSats(any(), anyOrNull())) + .thenReturn(Result.failure(ServiceError.CurrencyRateUnavailable())) + + sut.propose(profile(LEO_KEY, "Leo"), perPaymentUsd = 5, monthlyUsd = 100) {} + + verify(allowanceRepo, never()).propose(any(), any()) + } + + @Test + fun `a failed save keeps the sheet open and frees the button`() = test { + whenever(allowanceRepo.propose(any(), any())).thenReturn(Result.failure(PaykitAllowanceError.ContactNotLinked)) + var saved = false + + sut.propose(profile(LEO_KEY, "Leo"), perPaymentUsd = 5, monthlyUsd = 100) { saved = true } + + assertEquals(false, saved) + assertEquals(false, loadedState().isWorking) + } + + private fun TestScope.loadedState(): AllowancesUiState { + backgroundScope.launch { sut.uiState.collect {} } + runCurrent() + return sut.uiState.value.also { assertTrue(it.isLoaded) } + } + + private fun entry( + state: AllowanceLifecycleState = AllowanceLifecycleState.ACCEPTED, + perPaymentSats: ULong = 5_000uL, + monthlySats: ULong = 50_000uL, + limits: PaykitAllowanceLimits? = null, + ) = PaykitAllowanceEntry( + id = "entry-1", + allowances = listOf( + PaykitAllowance( + id = PaykitAllowance.Id(LEO_KEY, "bitkit/wallet", "allowance-1"), + role = PaykitAllowance.Role.ALLOWER, + lifecycleState = state, + isProposedByMe = true, + perPaymentMaxSats = perPaymentSats, + monthlyLimitSats = monthlySats, + monthlyAnchor = null, + ), + ), + limits = limits, + ) + + private fun profile(publicKey: String, name: String) = PubkyProfile.forDisplay( + publicKey = publicKey, + name = name, + imageUrl = null, + ) +} diff --git a/docs/screens-map.md b/docs/screens-map.md index bac060af52..960b08ceda 100644 --- a/docs/screens-map.md +++ b/docs/screens-map.md @@ -121,6 +121,7 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | Android | Figma | | - | - | +| AllowancesScreen.kt | `todo` | | CreateSubscriptionScreen.kt | Subscriptions › Create Subscription / Choose Subscription Recipient / Sent Subscription Proposal | | SubscriptionsScreen.kt | Subscriptions › Subscriptions Intro / Subscriptions overview | From a71c9afe222901e4adead6ed321e45bf22f85dd1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 17:49:45 +0200 Subject: [PATCH 07/12] feat: run allowances from the app lifecycle and send flow --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 98 ++++++++++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 8 ++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index fed62b981f..5338db94ff 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -161,6 +161,9 @@ import to.bitkit.repositories.PaykitPaymentRequestDiagnostics import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestError import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitAllowanceError +import to.bitkit.repositories.PaykitAllowanceEvent +import to.bitkit.repositories.PaykitAllowanceRepo import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.PaykitSubscription @@ -189,6 +192,7 @@ import to.bitkit.services.MigrationService import to.bitkit.services.NodeServiceFgState import to.bitkit.services.PubkyService import to.bitkit.ui.Routes +import to.bitkit.ui.components.AllowanceRoute import to.bitkit.ui.components.Sheet import to.bitkit.ui.components.SubscriptionRoute import to.bitkit.ui.shared.toast.ToastEventBus @@ -257,6 +261,7 @@ class AppViewModel @Inject constructor( private val publicPaykitRepo: PublicPaykitRepo, private val privatePaykitRepo: PrivatePaykitRepo, private val paykitPaymentRequestRepo: PaykitPaymentRequestRepo, + private val paykitAllowanceRepo: PaykitAllowanceRepo, private val paykitPaymentProofRepo: PaykitPaymentProofRepo, private val paykitPaymentRequestDiagnostics: PaykitPaymentRequestDiagnostics, private val refreshContactPaykitReceivers: RefreshContactPaykitReceiversUseCase, @@ -550,6 +555,7 @@ class AppViewModel @Inject constructor( observeInitialPaykitLinkBursts() observeIncomingPaykitPaymentRequests() observePaykitOnchainPaymentResolution() + observePaykitAllowanceEvents() observeSendEvents() viewModelScope.launch { checkCriticalAppUpdate() @@ -695,6 +701,7 @@ class AppViewModel @Inject constructor( preserveRequestedPaymentRequest = paymentRequestIdentity == null, ) paymentRequestIdentity = null + paykitAllowanceRepo.deactivate() try { paykitPaymentRequestRepo.clear() } finally { @@ -716,6 +723,7 @@ class AppViewModel @Inject constructor( isPaymentRequestIdentityActivating = true try { paykitPaymentRequestRepo.activate(state.publicKey) + paykitAllowanceRepo.activate(state.publicKey) if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, state.publicKey)) return paymentRequestIdentity = state.publicKey refreshPrivateOnlyPaykitReceiverMarker("contact sync") @@ -852,10 +860,54 @@ class AppViewModel @Inject constructor( if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return if (refreshMaintenance) paykitPaymentProofRepo.reconcile() paykitPaymentRequestRepo.refresh().onSuccess { + paykitAllowanceRepo.refresh() + if (paykitAllowanceRepo.processIncomingRequests(paykitPaymentRequestRepo.pendingRequests.value)) { + paykitPaymentRequestRepo.refresh() + } presentNextIncomingPaykitPaymentRequest() } } + private fun observePaykitAllowanceEvents() { + viewModelScope.launch { + paykitAllowanceRepo.events.collect(::handlePaykitAllowanceEvent) + } + } + + private fun handlePaykitAllowanceEvent(event: PaykitAllowanceEvent) { + when (event) { + is PaykitAllowanceEvent.PaidAutomatically -> toast( + type = Toast.ToastType.LIGHTNING, + title = context.getString(R.string.subscriptions__allowance_executed_title), + description = context.getString(R.string.subscriptions__allowance_executed_description) + .replace("{amount}", allowanceFiatAmount(event.amountSats)) + .replace("{name}", allowanceContactName(event.counterparty)), + testTag = "AllowancePaidToast", + ) + + is PaykitAllowanceEvent.LimitReached -> toast( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.subscriptions__allowance_limit_title), + description = context.getString(R.string.subscriptions__allowance_limit_description) + .replace("{amount}", allowanceFiatAmount(event.amountSats)) + .replace("{name}", allowanceContactName(event.counterparty)), + testTag = "AllowanceLimitToast", + ) + + PaykitAllowanceEvent.LedgerChanged -> Unit + } + } + + private fun allowanceFiatAmount(sats: ULong): String = + currencyRepo.convertSatsToFiat(sats.toLong()).getOrNull()?.let { "${it.symbol}${it.formatted}" } + ?: "$sats sats" + + private fun allowanceContactName(publicKey: String): String = + pubkyRepo.contacts.value.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, publicKey) } + ?.name + ?.takeIf { it.isNotBlank() } + ?: PubkyPublicKeyFormat.display(publicKey) + private suspend fun refreshPaymentRequestTargets(force: Boolean = false) { if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return paykitPaymentRequestRepo.refreshEligibleTargets( @@ -982,12 +1034,19 @@ class AppViewModel @Inject constructor( private suspend fun presentNextIncomingPaykitPaymentRequest() { if (isPresentingPaymentRequest || isPaymentRequestPresentationBlocked()) return if (requestedPaymentRequestId == null) { + paykitAllowanceRepo.proposalForPresentation()?.let { + showSheet(Sheet.Allowance(AllowanceRoute.Review(it.id))) + return + } paykitPaymentRequestRepo.automaticSubscriptionProposals().firstOrNull()?.let { showSheet(Sheet.Subscription(SubscriptionRoute.Review(it.id))) return } } - val requests = paymentRequestsForPresentation() ?: return + val requests = paymentRequestsForPresentation() + ?.filterNot { paykitAllowanceRepo.isAutomaticallyHandling(it) } + ?.takeIf { it.isNotEmpty() } + ?: return val generation = paymentRequestPresentationGeneration isPresentingPaymentRequest = true activePaymentRequestPresentationGeneration = generation @@ -3952,27 +4011,55 @@ class AppViewModel @Inject constructor( } } + val allowanceAttemptId = beginManualAllowancePayment(preparedPaymentProofRequest).getOrElse { + cancelPaymentProofPreparation(preparedPaymentProofRequest) + handlePaymentPreparationFailure(it, contactPaymentContext) + return + } + when (_sendUiState.value.payMethod) { SendMethod.ONCHAIN -> proceedWithOnchainPayment( incomingPaymentRequest, preparedPaymentProofRequest, contactPaymentContext, amount, + allowanceAttemptId, ) SendMethod.LIGHTNING -> proceedWithLightningPayment( incomingPaymentRequest, preparedPaymentProofRequest, amount, + allowanceAttemptId, ) } } + /** + * Reports a manual payment of an incoming request to the allowance ledger, so an allowance never pays the same + * request again. Only a payment the ledger already holds stops this one; any other ledger failure is logged. + */ + private suspend fun beginManualAllowancePayment(request: PaykitPaymentRequest?): Result { + if (request == null) return Result.success(null) + return paykitAllowanceRepo.beginManualPayment(request, paymentProofPreparation().endpointIdentifier) + .recoverCatching { + if (it is PaykitAllowanceError.PaymentAlreadyRecorded) throw it + Logger.warn("Failed to report a manual payment to the allowance ledger", it, context = TAG) + null + } + } + + private fun finishManualAllowancePayment(attemptId: String?, succeeded: Boolean?, transactionId: String? = null) { + if (attemptId == null) return + viewModelScope.launch { paykitAllowanceRepo.finishManualPayment(attemptId, succeeded, transactionId) } + } + private suspend fun proceedWithOnchainPayment( incomingPaymentRequest: PaykitPaymentRequest?, preparedPaymentProofRequest: PaykitPaymentRequest?, contactPaymentContext: ContactPaymentContext?, amount: ULong, + allowanceAttemptId: String?, ) { val address = _sendUiState.value.address val tags = _sendUiState.value.selectedTags @@ -3991,6 +4078,7 @@ class AppViewModel @Inject constructor( onBroadcast = { txId -> proofRequest = null completeOnchainPaymentProofInBackground(incomingPaymentRequest, txId) + finishManualAllowancePayment(allowanceAttemptId, succeeded = true, transactionId = txId) }, ).onSuccess { txId -> Logger.info("Onchain send result txid: $txId", context = TAG) @@ -4013,6 +4101,7 @@ class AppViewModel @Inject constructor( incomingPaymentRequest = incomingPaymentRequest, preparedPaymentProofRequest = proofRequest, contactPaymentContext = contactPaymentContext, + allowanceAttemptId = allowanceAttemptId, ) } } @@ -4023,10 +4112,12 @@ class AppViewModel @Inject constructor( incomingPaymentRequest: PaykitPaymentRequest?, preparedPaymentProofRequest: PaykitPaymentRequest?, contactPaymentContext: ContactPaymentContext?, + allowanceAttemptId: String? = null, ) { val amount = _sendUiState.value.amount if (paymentStarted && !error.isDefiniteOnchainPreBroadcastFailure()) { Logger.warn("On-chain payment outcome is uncertain after send started", error, context = TAG) + finishManualAllowancePayment(allowanceAttemptId, succeeded = null) uncertainOnchainPaymentRequestId = incomingPaymentRequest?.id paykitPaymentProofRepo.onchainPaymentResolutions.value .firstOrNull { it.requestId == incomingPaymentRequest?.id } @@ -4044,6 +4135,7 @@ class AppViewModel @Inject constructor( if (paymentStarted) { incomingPaymentRequest?.let { paykitPaymentProofRepo.failOnchainPayment(it) } } + finishManualAllowancePayment(allowanceAttemptId, succeeded = false) cancelPaymentProofPreparation(preparedPaymentProofRequest) Logger.error("Error sending onchain payment", error, context = TAG) if (contactPaymentContext?.isInitialSubscriptionPayment == true) { @@ -4062,6 +4154,7 @@ class AppViewModel @Inject constructor( incomingPaymentRequest: PaykitPaymentRequest?, preparedPaymentProofRequest: PaykitPaymentRequest?, amount: ULong, + allowanceAttemptId: String?, ) { val decodedInvoice = requireNotNull(_sendUiState.value.decodedInvoice) val paymentAmount = if (decodedInvoice.amountSatoshis > 0uL) null else amount @@ -4088,6 +4181,8 @@ class AppViewModel @Inject constructor( } val lnurlComment = savePendingLnurlComment(decodedInvoice, paymentHash) + // The node's payment events settle this attempt by hash, like an automatic one. + allowanceAttemptId?.let { paykitAllowanceRepo.manualLightningPaymentSent(it, paymentHash) } sendLightning(decodedInvoice.bolt11, paymentAmount).onSuccess { actualPaymentHash -> proofRequest = null @@ -4114,6 +4209,7 @@ class AppViewModel @Inject constructor( return@onFailure } cancelPaymentProofPreparation(proofRequest) + finishManualAllowancePayment(allowanceAttemptId, succeeded = false) createdMetadataPaymentId?.let { preActivityMetadataRepo.deletePreActivityMetadata(it) } lnurlComment?.let { activityRepo.clearPendingLightningMessage(paymentHash) } Logger.error("Error sending lightning payment", error, context = TAG) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 5a2e521729..6a84df4e8b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -131,6 +131,7 @@ import to.bitkit.repositories.PaykitPaymentRequestDiagnostics import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestError import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitAllowanceRepo import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.PaykitRecurrenceUnit @@ -230,6 +231,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val publicPaykitRepo = mock() private val privatePaykitRepo = mock() private val paykitPaymentRequestRepo = mock() + private val paykitAllowanceRepo = mock() private val paykitPaymentProofRepo = mock() private val paykitPaymentRequestDiagnostics = mock() private val samRockRepo = mock() @@ -383,6 +385,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.isExpired(any())).thenReturn(false) whenever(paykitPaymentRequestRepo.isProcessing(any())).thenReturn(false) whenever(paykitPaymentProofRepo.onchainPaymentResolutions).thenReturn(onchainPaymentResolutions) + whenever(paykitAllowanceRepo.events).thenReturn(MutableSharedFlow()) + whenever { paykitAllowanceRepo.refresh() }.thenReturn(Result.success(Unit)) + whenever { paykitAllowanceRepo.beginManualPayment(any(), any()) }.thenReturn(Result.success(null)) + whenever { paykitAllowanceRepo.processIncomingRequests(any()) }.thenReturn(false) + whenever { paykitAllowanceRepo.isAutomaticallyHandling(any()) }.thenReturn(false) whenever { paykitPaymentProofRepo.prepare(any(), any(), any()) }.thenReturn(Result.success(Unit)) whenever { paykitPaymentProofRepo.associateLightningPayment(any(), any(), any()) @@ -479,6 +486,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { publicPaykitRepo = publicPaykitRepo, privatePaykitRepo = privatePaykitRepo, paykitPaymentRequestRepo = paykitPaymentRequestRepo, + paykitAllowanceRepo = paykitAllowanceRepo, paykitPaymentProofRepo = paykitPaymentProofRepo, paykitPaymentRequestDiagnostics = paykitPaymentRequestDiagnostics, refreshContactPaykitReceivers = refreshContactPaykitReceivers, From 89a2fba974b7aed256f3174edb5ff3a3cbb25266 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 18:27:43 +0200 Subject: [PATCH 08/12] fix: wait for the payee's next payment list instead of asking the payer --- .../repositories/PaykitAllowanceExecutor.kt | 6 +++++- .../repositories/PaykitAllowanceRepo.kt | 6 +++++- .../bitkit/repositories/PrivatePaykitRepo.kt | 15 +++++++++++-- .../PaykitAllowanceExecutorTest.kt | 10 +++++++++ .../PrivatePaykitAllowancePaymentTest.kt | 21 +++++++++++++++++-- 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index 450df5cd34..7fcc7b96b3 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -384,7 +384,11 @@ class PaykitAllowanceExecutor @Inject constructor( return PaykitAllowanceAutoPayResult.MANUAL } - val payment = payer.resolve(request, candidate.eligiblePaymentEndpointIdentifiers).getOrThrow() + val payment = payer.resolve(request, candidate.eligiblePaymentEndpointIdentifiers).getOrElse { + if (it !is PaykitAllowanceError.PaymentListPending) throw it + Logger.info("Deferred an incoming request until the payee publishes a new payment list", context = TAG) + return PaykitAllowanceAutoPayResult.DEFERRED + } if (payment == null) { Logger.info("Kept an incoming request manual: no payable private endpoint", context = TAG) return PaykitAllowanceAutoPayResult.MANUAL diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt index a73663ac00..707746f8c6 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -65,12 +65,16 @@ enum class PaykitAllowanceAutoPayResult { /** The on-chain payment was broadcast and recorded. */ COMPLETED, + + /** The payee has not published a payment list newer than the one last paid; the next refresh tries again. */ + DEFERRED, } sealed class PaykitAllowanceError(message: String) : AppError(message) { data object ContactNotLinked : PaykitAllowanceError("The contact has no linked Paykit receiver") data object Unavailable : PaykitAllowanceError("The allowance is unavailable") data object PaymentAlreadyRecorded : PaykitAllowanceError("A payment for this request is already in progress") + data object PaymentListPending : PaykitAllowanceError("The payee has not published a new payment list yet") } /** @@ -401,7 +405,7 @@ class PaykitAllowanceRepo @Inject constructor( when (result) { PaykitAllowanceAutoPayResult.STARTED, PaykitAllowanceAutoPayResult.COMPLETED -> handledAny = true PaykitAllowanceAutoPayResult.MANUAL -> manualRequestSignatures[request.id] = signature - PaykitAllowanceAutoPayResult.NOT_COVERED -> Unit + PaykitAllowanceAutoPayResult.NOT_COVERED, PaykitAllowanceAutoPayResult.DEFERRED -> Unit } } if (handledAny) refresh() diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 8b6fc45f95..69a620fc76 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -407,8 +407,19 @@ class PrivatePaykitRepo @Inject constructor( afterPrivatePaymentListVersion = consumedVersion, amount = PaymentAmountContext(request.amountValue, PaykitIssuerInterop.BITCOIN_ASSET), ) - val paymentListVersion = prepared.resolution.privatePaymentListVersion - ?: return@runSuspendCatching null + val resolution = prepared.resolution + if ( + resolution.state == PrivatePaymentResolutionState.RECOVERY_PENDING || + resolution.status == PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST + ) { + // The last list was already paid from; a new one arrives once the payee sees that payment settle. + schedulePendingPrivateMessageDrainRetries( + reason = "allowance payment", + retryKeys = listOf(PrivateMessageDrainRetryKey(publicKey, request.counterpartyReceiverPath)), + ) + throw PaykitAllowanceError.PaymentListPending + } + val paymentListVersion = resolution.privatePaymentListVersion ?: return@runSuspendCatching null val eligible = eligibleIdentifiers.toSet() intersect request.acceptedPaymentEndpointIdentifiers.toSet() val candidates = prepared.resolution.payableEndpoints diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt index 6d364491f1..1acae70fbe 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -291,6 +291,16 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { verify(payer).payLightning(eq(INVOICE), eq(1_000uL)) } + @Test + fun `request waits without acceptance while the payee's payment list is pending`() = test { + whenever(payer.resolve(any(), any())).thenReturn(Result.failure(PaykitAllowanceError.PaymentListPending)) + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.DEFERRED, result) + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any()) + } + @Test fun `blocked candidate stays manual without acceptance`() = test { candidates = listOf(candidate(blocked = AllowanceAccountingBlock.SharedRule("amount_outside_range"))) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt index 2e4ae32b6b..a84eb156a7 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt @@ -183,6 +183,19 @@ class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) assertNull(sut.resolveAllowancePayment(paymentRequest(), eligible(MethodId.P2wpkh)).getOrThrow()) } + @Test + fun `allowance payment waits for a payment list newer than the one already paid`() = test { + stubResolution( + resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), + version = null, + status = PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST, + ) + + val result = sut.resolveAllowancePayment(paymentRequest(), eligible(MethodId.Bolt11)) + + assertEquals(PaykitAllowanceError.PaymentListPending, result.exceptionOrNull()) + } + @Test fun `expired request is never resolved`() = test { val expired = paymentRequest().copy(expiresAt = Instant.fromEpochSeconds(NOW_SECONDS - 1)) @@ -191,12 +204,16 @@ class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) verify(paykitSdkService, never()).prepareAndResolvePrivateContactPayment(any(), any(), anyOrNull(), anyOrNull()) } - private suspend fun stubResolution(vararg endpoints: PaykitResolvedPaymentEndpoint, version: ULong? = 7uL) { + private suspend fun stubResolution( + vararg endpoints: PaykitResolvedPaymentEndpoint, + version: ULong? = 7uL, + status: PrivatePaymentResolutionStatus = PrivatePaymentResolutionStatus.PAYABLE, + ) { whenever(paykitSdkService.prepareAndResolvePrivateContactPayment(any(), any(), anyOrNull(), anyOrNull())) .thenReturn( PaykitPreparedPrivateContactPayment( resolution = PaykitPrivateContactPaymentResolution( - status = PrivatePaymentResolutionStatus.PAYABLE, + status = status, state = PrivatePaymentResolutionState.AVAILABLE, privatePaymentListVersion = version, payableEndpoints = endpoints.toList(), From 309af54b796fc3ea1455bf4e8d1cb58a71f3c6cb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 18:31:53 +0200 Subject: [PATCH 09/12] fix: hold automatic payments until the node's channels are usable --- .../repositories/PaykitAllowanceRepo.kt | 12 +++++++++- .../repositories/PaykitAllowanceRepoTest.kt | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt index 707746f8c6..f51e2e4e15 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -370,7 +370,8 @@ class PaykitAllowanceRepo @Inject constructor( val covered = requests.filter { it.requiresAcceptance && coversRequest(it) && manualRequestSignatures[it.id] != signature } - if (covered.isEmpty() || !isProcessingRequests.compareAndSet(false, true)) return@withContext false + if (covered.isEmpty() || !canPayNow()) return@withContext false + if (!isProcessingRequests.compareAndSet(false, true)) return@withContext false try { payCovered(covered, identity, signature) @@ -390,6 +391,15 @@ class PaykitAllowanceRepo @Inject constructor( manualRequestSignatures[request.id] != allowancesSignature() } + /** + * A node that just started lists its channels before they reconnect, and a payment sent then fails with no route. + * Covered requests wait for the next refresh instead of falling back to the manual flow. + */ + private fun canPayNow(): Boolean { + val state = lightningRepo.lightningState.value + return state.nodeLifecycleState.isRunning() && (state.channels.isEmpty() || state.channels.any { it.isUsable }) + } + /** Request ids paid automatically, for the "Auto-paid" tag in payment history. */ fun isAutoPaid(id: PaykitPaymentRequestId): Boolean = id in _autoPaidRequestIds.value diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt index d00148cd36..a50f024a97 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt @@ -1,5 +1,8 @@ package to.bitkit.repositories +import org.mockito.kotlin.doReturn +import kotlinx.collections.immutable.persistentListOf +import org.lightningdevkit.ldknode.ChannelDetails import com.synonym.paykit.AllowanceHistoryStatus import com.synonym.paykit.AllowanceLifecycleState import com.synonym.paykit.AllowanceLocalRole @@ -342,6 +345,7 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { @Test fun `covered request stays off the Send sheet until it is found manual`() = test { + nodeReady() records = listOf(fixtures.record()) whenever(executor.autoPay(any(), any(), any())).thenReturn(PaykitAllowanceAutoPayResult.MANUAL) sut.activate(identity) @@ -372,6 +376,7 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { @Test fun `started payment is reported and refreshes the allowances`() = test { + nodeReady() records = listOf(fixtures.record()) sut.activate(identity) val uncovered = fixtures.paymentRequest(id = "other", counterparty = fixtures.otherCounterpartyKey) @@ -384,6 +389,21 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { verify(sdk, times(2)).listAllowances(any()) } + @Test + fun `covered request waits while the node's channels reconnect`() = test { + records = listOf(fixtures.record()) + sut.activate(identity) + val reconnecting = mock { on { isUsable } doReturn false } + lightningState.update { + it.copy(nodeLifecycleState = NodeLifecycleState.Running, channels = persistentListOf(reconnecting)) + } + val request = fixtures.paymentRequest() + + assertFalse(sut.processIncomingRequests(listOf(request))) + assertTrue(sut.isAutomaticallyHandling(request)) + verify(executor, never()).autoPay(any(), any(), any()) + } + @Test fun `nothing is processed without an identity`() = test { assertFalse(sut.processIncomingRequests(listOf(fixtures.paymentRequest()))) @@ -443,6 +463,8 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { // region Helpers + private fun nodeReady() = lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } + private fun group(vararg allowanceIds: String) = PaykitAllowanceLocalState.Group( id = "group-1", counterparty = fixtures.counterpartyKey, From c74a8fd41a5e15f9077b490d767bfd99220497d9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 18:56:41 +0200 Subject: [PATCH 10/12] fix: notify allowance payments and limits over the request sheet --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 5338db94ff..e0cb26d6e9 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -191,10 +191,12 @@ import to.bitkit.services.CoreService import to.bitkit.services.MigrationService import to.bitkit.services.NodeServiceFgState import to.bitkit.services.PubkyService +import to.bitkit.ui.ID_NOTIFICATION_SKIPPED import to.bitkit.ui.Routes import to.bitkit.ui.components.AllowanceRoute import to.bitkit.ui.components.Sheet import to.bitkit.ui.components.SubscriptionRoute +import to.bitkit.ui.pushNotification import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute @@ -876,7 +878,7 @@ class AppViewModel @Inject constructor( private fun handlePaykitAllowanceEvent(event: PaykitAllowanceEvent) { when (event) { - is PaykitAllowanceEvent.PaidAutomatically -> toast( + is PaykitAllowanceEvent.PaidAutomatically -> notifyAllowanceEvent( type = Toast.ToastType.LIGHTNING, title = context.getString(R.string.subscriptions__allowance_executed_title), description = context.getString(R.string.subscriptions__allowance_executed_description) @@ -885,7 +887,7 @@ class AppViewModel @Inject constructor( testTag = "AllowancePaidToast", ) - is PaykitAllowanceEvent.LimitReached -> toast( + is PaykitAllowanceEvent.LimitReached -> notifyAllowanceEvent( type = Toast.ToastType.WARNING, title = context.getString(R.string.subscriptions__allowance_limit_title), description = context.getString(R.string.subscriptions__allowance_limit_description) @@ -898,6 +900,15 @@ class AppViewModel @Inject constructor( } } + /** + * Allowance events post a system notification when allowed, as on iOS: the request sheet that opens right after a + * limit is reached would cover an in-app toast. Without the permission they fall back to a toast. + */ + private fun notifyAllowanceEvent(type: Toast.ToastType, title: String, description: String, testTag: String) { + if (context.pushNotification(title, description) != ID_NOTIFICATION_SKIPPED) return + toast(type = type, title = title, description = description, testTag = testTag) + } + private fun allowanceFiatAmount(sats: ULong): String = currencyRepo.convertSatsToFiat(sats.toLong()).getOrNull()?.let { "${it.symbol}${it.formatted}" } ?: "$sats sats" From 155f6abf5d70697f5787e05c00925fdad77f5f16 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 25 Sep 2026 00:19:09 +0200 Subject: [PATCH 11/12] docs: add the allowance journeys --- journeys/allowances/README.md | 51 +++++++++++++++++++ journeys/allowances/above-limit-asks.xml | 19 +++++++ journeys/allowances/auto-pay-under-limit.xml | 22 ++++++++ journeys/allowances/end-stops-auto-pay.xml | 26 ++++++++++ journeys/allowances/monthly-cap-reached.xml | 22 ++++++++ .../allowances/restart-never-pays-twice.xml | 21 ++++++++ journeys/allowances/set-and-accept.xml | 26 ++++++++++ 7 files changed, 187 insertions(+) create mode 100644 journeys/allowances/README.md create mode 100644 journeys/allowances/above-limit-asks.xml create mode 100644 journeys/allowances/auto-pay-under-limit.xml create mode 100644 journeys/allowances/end-stops-auto-pay.xml create mode 100644 journeys/allowances/monthly-cap-reached.xml create mode 100644 journeys/allowances/restart-never-pays-twice.xml create mode 100644 journeys/allowances/set-and-accept.xml diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md new file mode 100644 index 0000000000..da9ccc442f --- /dev/null +++ b/journeys/allowances/README.md @@ -0,0 +1,51 @@ +# Allowances journeys + +Cover the Paykit allowance lifecycle between two Bitkit instances: the payer sets an allowance for a +contact, the payee accepts it, requests within the limits are paid without asking, a request above a +limit falls back to the normal Payment Request sheet, and either side ends it. The restart journey +pins the one rule that must never break: a payment interrupted mid-flight is never paid twice. + +## Setup + +Run Bitkit against regtest with Paykit UI enabled on two instances that have each other saved as +contacts and linked on receiver path `bitkit/wallet`, exactly as for +[`../subscriptions/README.md`](../subscriptions/README.md). One instance plays the payer (the one +that sets the allowance), the other the payee (the one that sends requests). Automatic payments go +over Lightning only, so the payer needs a spending balance above 50,000 sats with a usable channel, +and the payee needs receiving capacity; fund both through the staging LSP. + +Grant the payer notification permission first (`adb shell pm grant to.bitkit.dev +android.permission.POST_NOTIFICATIONS`): the "Payment Executed" and "Limit Reached" events post a +system notification when they can, and fall back to a toast that `android layout` cannot see. + +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 +at the current rate, so a sats amount next to them will differ between runs. + +## Reference evidence + +The source run drove every journey on 2026-09-24 across two Android 16 emulators against the +staging regtest LSP, with Paykit built from pubky/paykit-rs#161. A $2 and a $4 request were paid +about two seconds after arriving, a $20 request asked, the third $4 request on a $10 monthly cap +raised Limit Reached, ending the allowance from either side stopped automatic payments, and a payer +killed right after handing the payment to the node never paid twice after relaunch. + +## Identifiers used + +- Tab: `Tab-allowances`; empty state `AllowancesEmpty`; footer button `AllowanceAdd` +- Contact picker: `AllowanceContact-` +- Set sheet: `SetAllowance`, sliders `AllowancePerPayment` and `AllowanceMonthly` with stops + `AllowancePerPaymentStop-` and `AllowanceMonthlyStop-`, `AllowanceSummary`, + `AllowanceSave` +- List row: `AllowanceRow-` with its status line `AllowanceRowStatus` +- Review sheet (payee): `AllowanceReview`, `AllowanceCounterparty`, `AllowancePerPaymentValue`, + `AllowanceMonthlyValue`, `AllowanceAccept`, `AllowanceDecline` +- Detail sheet: `AllowanceDetail`, `AllowancePaidSoFar`, `AllowanceDetailStatus` +- Payments tab: `Tab-payments`, `PaymentRequestCreate`, `PaymentRequestRow-` + whose subtitle ends with "· Auto-paid" for an automatic payment +- Incoming request: `PaymentRequestsBell`, `PaymentRequestsSheet` + +The review sheet on the payee opens on its own about a second after the proposal arrives; no tap is +needed to reach it. `android layout` can omit test tags applied to plain `Box` and `Column` +containers; use the raw UI Automator hierarchy when a documented container tag is missing. diff --git a/journeys/allowances/above-limit-asks.xml b/journeys/allowances/above-limit-asks.xml new file mode 100644 index 0000000000..598cfe4932 --- /dev/null +++ b/journeys/allowances/above-limit-asks.xml @@ -0,0 +1,19 @@ + + + A request above the per-payment limit is never paid automatically: it arrives as an ordinary + Payment Request that the payer pays by swiping, and a manual payment does not count toward the + amount paid automatically. Requires the Active $5 a payment allowance from set-and-accept.xml + and a payer balance above the request. + + + Launch the E2E Bitkit app on both instances with the $5 a payment, $50 a month allowance Active and at least one automatic payment made, per auto-pay-under-limit.xml + On the payee instance, open Subscriptions, the Payments tab (testTag "Tab-payments"), tap Request Payment (testTag "PaymentRequestCreate") and send the payer a one-time Payment Request for $20 with the note "Artpack" + Switch to the payer instance + Verify the Payment Request sheet (testTag "PaymentRequestsSheet") opens for $20.00 from the payee, or the bell (testTag "PaymentRequestsBell") shows one pending request, and that nothing was sent automatically + Verify no "Payment Executed" notification was posted for this request + Pay it from the sheet (testTag "PaymentRequestPay-<paymentRequestId>") and complete Swipe To Pay + Verify Bitcoin Sent shows about $20.00 + Open Subscriptions, tap the Payments tab and verify the "Artpack" row is listed without "· Auto-paid" in its subtitle + Tap the Allowances tab, tap the payee's row and verify PAID AUTOMATICALLY (testTag "AllowancePaidSoFar") still shows only the automatic payments, not the $20 + + diff --git a/journeys/allowances/auto-pay-under-limit.xml b/journeys/allowances/auto-pay-under-limit.xml new file mode 100644 index 0000000000..e539f7e258 --- /dev/null +++ b/journeys/allowances/auto-pay-under-limit.xml @@ -0,0 +1,22 @@ + + + A request within both limits is paid without the payer seeing a sheet. The payer only gets a + "Payment Executed" notification, the payee receives the payment, and the payer's Payments tab and + allowance detail both account for it. Requires the Active allowance from set-and-accept.xml and + notification permission granted on the payer. + + + Launch the E2E Bitkit app on both instances with the $5 a payment, $50 a month allowance from set-and-accept.xml Active, and the payer's notification permission granted + On the payee instance, open the drawer menu, tap Subscriptions, tap the Payments tab (testTag "Tab-payments") and tap Request Payment (testTag "PaymentRequestCreate") + Send the payer a one-time Payment Request for $2 with the note "Devlog" + Switch to the payer instance, with Bitkit in the foreground on the home screen + Verify that within about five seconds no Payment Request sheet opens and the bell (testTag "PaymentRequestsBell") shows no pending request + Verify a "Payment Executed" notification with the text "Auto-pay sent $2.00 to <payee>" is posted, reading it back with `adb shell dumpsys notification --noredact`; without notification permission it is a toast that only a screenshot catches + Switch to the payee instance and verify the payment arrives, either as the Received Instant Bitcoin sheet or as a $2.00 "Received from <payer>" row in Activity + Switch to the payer instance, open the drawer menu, tap Subscriptions and tap the Payments tab + Verify the "Devlog" row (testTag "PaymentRequestRow-<paymentRequestId>") is listed with a subtitle ending in "· Auto-paid" + Tap the Allowances tab, then tap the payee's row + Verify the detail sheet (testTag "AllowanceDetail") shows PAID AUTOMATICALLY "$2.00" (testTag "AllowancePaidSoFar") and the explanation "Requests within these limits are paid automatically. Anything above them asks you first." (testTag "AllowanceDetailStatus") + Open Activity from the home screen and verify the newest row reads "Sent to <payee>" for $2.00 + + diff --git a/journeys/allowances/end-stops-auto-pay.xml b/journeys/allowances/end-stops-auto-pay.xml new file mode 100644 index 0000000000..ab8d7a761b --- /dev/null +++ b/journeys/allowances/end-stops-auto-pay.xml @@ -0,0 +1,26 @@ + + + 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. + + + 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 + On the payer instance, open the drawer menu, tap Subscriptions, tap the Allowances tab and tap the payee's row + Verify the detail sheet (testTag "AllowanceDetail") ends with a swipe control reading "Swipe To End Allowance" + Swipe the control to the end + 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 + 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 + 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 · $0.00 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 + + diff --git a/journeys/allowances/monthly-cap-reached.xml b/journeys/allowances/monthly-cap-reached.xml new file mode 100644 index 0000000000..4f6cd032c2 --- /dev/null +++ b/journeys/allowances/monthly-cap-reached.xml @@ -0,0 +1,22 @@ + + + Requests keep paying themselves until the month's allowance is used up; the first request that + would exceed it is left to the payer with a "Limit Reached" notification and the ordinary Payment + Request sheet. The journey uses a $5 a payment, $10 a month allowance so two $4 requests fit and + the third does not. The second request can take a few extra seconds: after a payment the payee + publishes a new private payment list, and the payer waits for it rather than asking. + + + 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 + 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>" + On the payee instance, wait for the payment to arrive, then send a second $4 request with the note "Cap2" + On the payer instance, verify it is paid without a sheet within about fifteen seconds, and a second "Payment Executed" notification is posted + On the payee instance, send a third $4 request with the note "Cap3" + On the payer instance, verify a "Limit Reached" notification reads "A $4.00 request from <payee> is above your allowance. Review it to pay." and the Payment Request sheet opens for $4.00 + Dismiss the request (testTag "PaymentRequestDismiss-<paymentRequestId>") + Open Subscriptions, tap the Allowances tab, tap the payee's row and verify PAID AUTOMATICALLY (testTag "AllowancePaidSoFar") reads "$8.00" + + diff --git a/journeys/allowances/restart-never-pays-twice.xml b/journeys/allowances/restart-never-pays-twice.xml new file mode 100644 index 0000000000..21426038f4 --- /dev/null +++ b/journeys/allowances/restart-never-pays-twice.xml @@ -0,0 +1,21 @@ + + + Killing the payer while an automatic payment is in flight must never lead to a second payment + after relaunch. The app records the request as taken before it hands the payment to the node, so + on relaunch it either sees the node finish the first attempt or hands the request back to the + payer as an ordinary Payment Request; it never starts a new automatic attempt. The kill has to + land within about two seconds of the request arriving, which the app log marks with "Handed an + allowance payment to the node". + + + Launch the E2E Bitkit app on both instances with the $5 a payment, $50 a month allowance Active, per set-and-accept.xml + On the payer instance, start tailing the app log for "Handed an allowance payment to the node" so the kill can follow it at once + On the payee instance, send the payer a one-time Payment Request for $2 with the note "Devlog3" + As soon as the log line appears on the payer, run `adb shell am force-stop to.bitkit.dev` + Relaunch the payer with `adb shell am start -n to.bitkit.dev/to.bitkit.ui.MainActivity` and wait for the node to come back up + Verify no "Payment Executed" notification is posted for a second attempt after the relaunch + Open the drawer menu, tap Subscriptions and tap the Payments tab; verify "Devlog3" is listed exactly once + On the payee instance, verify at most one $2.00 payment arrives for "Devlog3" + If the kill landed before the node took the payment: on the payer, verify "Devlog3" comes back as an ordinary Payment Request in the bell (testTag "PaymentRequestsBell") rather than being paid automatically, and dismiss it + + diff --git a/journeys/allowances/set-and-accept.xml b/journeys/allowances/set-and-accept.xml new file mode 100644 index 0000000000..60c12949e9 --- /dev/null +++ b/journeys/allowances/set-and-accept.xml @@ -0,0 +1,26 @@ + + + The payer sets an allowance for a contact from the Allowances tab, and the payee sees the offer + open by itself and accepts it. Until the payee answers, the payer's row reads "Waiting for an + answer"; after it, both sides show the allowance as Active. The other allowance journeys start + from the Active state this one ends in. + + + Launch the E2E Bitkit app with Paykit UI enabled on both instances, each with the other saved as a linked contact, the payer holding a spendable balance above 50,000 sats with a usable Lightning channel + On the payer instance, open the drawer menu and tap Subscriptions + Tap the Allowances tab (testTag "Tab-allowances") + Verify the empty state (testTag "AllowancesEmpty") reads "Set up allowances" over the group illustration, with the footer button "Add Allowance" + Tap Add Allowance (testTag "AllowanceAdd") + Verify the Choose Contact sheet lists the payee and tap it (testTag "AllowanceContact-<displayName>") + Verify the Set Allowance sheet (testTag "SetAllowance") shows the payee's card, a PAYMENT LIMIT slider (testTag "AllowancePerPayment") and a MONTHLY ALLOWANCE slider (testTag "AllowanceMonthly") + Tap the $5 stop of the payment limit slider (testTag "AllowancePerPaymentStop-1") and the $50 stop of the monthly slider (testTag "AllowanceMonthlyStop-1") + Verify the summary (testTag "AllowanceSummary") reads "Requests up to $5 will be paid automatically, up to $50 a month, without asking you each time." + Tap Save Allowance (testTag "AllowanceSave") + Verify the sheet dismisses and the list shows one row for the payee (testTag "AllowanceRow-<allowanceId>") whose status line (testTag "AllowanceRowStatus") reads "Waiting for an answer", with "$ 50.00" over "Monthly limit" on the right + Switch to the payee instance and bring Bitkit to the foreground + Verify the Allowance review sheet (testTag "AllowanceReview") opens on its own within a few seconds, headed "<payer> offers an allowance", with the payer's contact card (testTag "AllowanceCounterparty"), PER PAYMENT "Up to $5.00" (testTag "AllowancePerPaymentValue") and EACH MONTH "Up to $50.00" (testTag "AllowanceMonthlyValue") + Tap Accept (testTag "AllowanceAccept") + Verify the sheet dismisses; open the drawer menu, tap Subscriptions, tap the Allowances tab and verify the payer's row reads "Active" followed by the per-payment limit + Switch back to the payer instance and verify its row now reads "Active · $5 a payment" + + From 0dde3eb8a2c2f5ceb9ce318a3f738f0bda2da7a2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 25 Sep 2026 00:25:44 +0200 Subject: [PATCH 12/12] chore: add changelog fragment --- changelog.d/next/1340.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1340.added.md diff --git a/changelog.d/next/1340.added.md b/changelog.d/next/1340.added.md new file mode 100644 index 0000000000..6e89aeb684 --- /dev/null +++ b/changelog.d/next/1340.added.md @@ -0,0 +1 @@ +Allowances: set a per-payment and a monthly limit for a Paykit contact so their payment requests within the limits are paid automatically.