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/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index 1a8df74c5d..8dddf736ea 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -238,6 +238,7 @@ class Keychain @Inject constructor( PAYKIT_PENDING_PAYMENT_PROOFS, PAYKIT_ACCEPTED_PAYMENT_REQUESTS, PAYKIT_PRESENTED_PAYMENT_REQUESTS, + PAYKIT_ALLOWANCE_STATE, PUBKY_SECRET_KEY, SHARED_PUBKY_SOURCE, } 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 } 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..bf65975479 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt @@ -0,0 +1,269 @@ +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 to.bitkit.models.safe +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's identity and one contact identity, 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 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 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.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 Allowance with a contact identity and the limits picked for it. */ +@Immutable +data class PaykitAllowanceEntry( + val id: String, + val allowances: List, + val limits: PaykitAllowanceLimits?, +) { + /** An accepted Allowance before any other. */ + 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.safe() + attempt.amountSats.safe() } + } + + 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).safe() + amountSats.safe() <= 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/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt new file mode 100644 index 0000000000..50bdff9855 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -0,0 +1,783 @@ +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.NonCancellable +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, + private val paymentRequestRepo: PaykitPaymentRequestRepo, +) { + suspend fun resolve( + request: PaykitPaymentRequest, + eligibleIdentifiers: List, + ): Result = privatePaykitRepo.resolveAllowancePayment(request, eligibleIdentifiers) + + suspend fun consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext): Result = + privatePaykitRepo.consumePrivatePaymentList(publicKey, context) + + /** Accepts through [accept] with this install as the request's owner, so a failed payment stays payable here. */ + suspend fun acceptOnThisInstall(request: PaykitPaymentRequest, accept: suspend () -> T): Result = + paymentRequestRepo.acceptOnThisInstall(request, accept) + + suspend fun prepareProof( + request: PaykitPaymentRequest, + paymentEndpointIdentifier: String, + paymentAppId: String, + allowanceId: String?, + ): Result { + val kind = PaykitPaymentProofKind.fromPaymentEndpointIdentifier(paymentEndpointIdentifier) + ?: return Result.failure(PaykitPaymentRequestError.RequestUnavailable) + return paymentProofRepo.prepare(request, paymentEndpointIdentifier, paymentAppId, kind, allowanceId) + } + + suspend fun associateLightningPayment( + request: PaykitPaymentRequest, + paymentHash: String, + paymentEndpointIdentifier: String, + paymentAppId: String, + ): Result = + paymentProofRepo.associateLightningPayment(request, paymentHash, paymentEndpointIdentifier, paymentAppId) + + 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, + paymentAppId: String, + ) { + paymentProofRepo.completeOnchainPayment(request, txid, paymentEndpointIdentifier, paymentAppId) + } + + /** 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, so there is nothing to recover. + 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) + } + if (!isCovered || !inFlightRequestIds.add(request.id)) { + return@withContext PaykitAllowanceAutoPayResult.NOT_COVERED + } + + try { + runSuspendCatching { + ensureReconciled(identity) + // Survive a cancelled caller: stopping after the acceptance would leave the request unpaid. + withContext(NonCancellable) { 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).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 + } + + val endpointIdentifier = payment.endpoint.methodId.rawValue + val association = payer.acceptOnThisInstall(request) { + // Every identity-wide execution step needs this app to own the request's execution claim first. + paykitSdkService.claimPaymentRequestForExecution(request.counterparty, request.paymentRequestId) + paykitSdkService.acceptPaymentRequestAutomatically( + scope = scope, + selection = AllowanceSelectionInput( + allowanceId = candidate.allowanceId, + expectedRevision = null, + trustedTime = selectionTime, + ), + checks = checks(request, endpointIdentifier, selectionTime), + ) + }.getOrThrow() + 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) + }.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, payment.appId, 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, + payment.appId, + ).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, payment.appId) + 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) + }.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, 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 new file mode 100644 index 0000000000..2210134d7f --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -0,0 +1,491 @@ +package to.bitkit.repositories + +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.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.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 +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 LimitReached(val counterparty: String, val amountSats: ULong) : PaykitAllowanceEvent + + data object LedgerChanged : PaykitAllowanceEvent +} + +enum class PaykitAllowanceAutoPayResult { + /** No accepted Allowance covers the request's contact, 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, + + /** 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 identity") + 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") +} + +/** + * Allowances for the active identity. One user-facing entry is one grant to a contact's identity, which covers every + * Paykit app of that contact, 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 +@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) } + } + + 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 by contact identity. */ + 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() + + 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 } + + // 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()) + } + + suspend fun refresh(): Result = withContext(ioDispatcher) { + val identity = activeIdentity ?: return@withContext Result.success(Unit) + refreshMutex.withLock { + val listing = listAllowances() + if (activeIdentity == identity) { + publish(listing.getOrDefault(_allowances.value), executor.localState(identity)) + } + listing.map {} + } + } + + private suspend fun listAllowances(): Result> = runSuspendCatching { + paykitSdkService.listAllowances( + AllowanceFilter( + counterparty = 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) } + + /** Proposes the terms to the contact's identity, which needs a linked 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 isLinked = paykitSdkService.linkedPeers().any { + PubkyPublicKeyFormat.matches(it.counterparty, contactKey) && it.state == LinkedPeerState.LINKED + } + if (!isLinked) throw PaykitAllowanceError.ContactNotLinked + + val terms = allowanceTerms( + limits, + PaykitAllowanceTime.monthStart(clock.now()), + allowedPaymentEndpointIdentifiers(), + ) + val allowance = paykitSdkService.proposeAllowance(contactKey, AllowanceLocalRole.ALLOWER, terms) + sendQueuedMessages(contactKey) + val group = PaykitAllowanceLocalState.Group( + id = UUID.randomUUID().toString(), + counterparty = contactKey, + limits = limits, + allowanceIds = listOf(allowance.allowanceId), + createdAtMillis = clock.now().toEpochMilliseconds(), + ) + executor.updateLocalState(identity) { it.copy(groups = it.groups + group) } + Logger.info("Proposed an allowance", context = TAG) + refresh() + Unit + } + } + + suspend fun accept(entryId: String): Result = respond(entryId) { + paykitSdkService.acceptAllowance(it.counterparty, it.allowanceId) + } + + suspend fun reject(entryId: String): Result = respond(entryId) { + paykitSdkService.rejectAllowance(it.counterparty, it.allowanceId) + } + + 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.allowanceId) + sendQueuedMessages(allowance.counterparty) + } + refresh() + Unit + } + } + + 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) + } + val ids = entry.allowances.map { it.allowanceId } + executor.updateLocalState(identity) { it.copy(presentedProposalIds = it.presentedProposalIds + ids) } + refresh() + Unit + } + } + + private suspend fun sendQueuedMessages(counterparty: String) { + runSuspendCatching { paykitSdkService.processOutboundPrivateMessages(counterparty) } + .onFailure { Logger.warn("Failed to send allowance messages 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 contact identity. 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) && + isCreatedAfterAcceptance(request, allowance) + } + } + + /** Pays covered incoming requests headlessly; returns true when any request was handled. */ + 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() || !canPayNow()) return@withContext false + if (!isProcessingRequests.compareAndSet(false, true)) return@withContext false + + 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() + } + + /** + * 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 + + 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, PaykitAllowanceAutoPayResult.DEFERRED -> Unit + } + } + if (handledAny) refresh() + return handledAny + } + + 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 + } + + 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) { + 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)) + } + + // endregion + + 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 593f6f5b05..2374d073c8 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -70,6 +70,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( @@ -140,10 +142,20 @@ class PaykitPaymentProofRepo @Inject constructor( paymentEndpointIdentifier: String, paymentAppId: String, kind: PaykitPaymentProofKind, + ): Result = prepare(request, paymentEndpointIdentifier, paymentAppId, kind, allowanceId = null) + + /** [allowanceId] is set only for an automatic Allowance payment; the submitted proof carries it. */ + suspend fun prepare( + request: PaykitPaymentRequest, + paymentEndpointIdentifier: String, + paymentAppId: String, + kind: PaykitPaymentProofKind, + allowanceId: String?, ): Result = withContext(ioDispatcher) { runSuspendCatching { operationMutex.withLock { val proof = pendingProof(request, paymentEndpointIdentifier, paymentAppId, kind) + .copy(allowanceId = allowanceId) val currentProofs = loadProofs() if (currentProofs.any { it.isStartedFor(proof.identity, request.id) }) { throw PaykitPaymentRequestError.OperationInProgress @@ -549,6 +561,7 @@ class PaykitPaymentProofRepo @Inject constructor( paymentAppId = proof.paymentAppId, 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/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index cabf87c262..ab603d0196 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -840,34 +840,12 @@ class PaykitPaymentRequestRepo @Inject constructor( } } else { updateRequest(request, PaymentRequestLifecycleState.ACCEPTED) { - val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable - val generation = stateGeneration.get() - ensurePaymentAllowed(it, forExecution = false).getOrThrow() - val alreadySaved = it.id in presentationStore.loadAcceptedOneTimeIds(identity) - val acceptedIds = presentationStore.addAcceptedOneTimeId(identity, it.id) - if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable - acceptedOneTimeRequestIds = acceptedIds - runSuspendCatching { + withAcceptanceIntent(it) { paykitSdkService.acceptPaymentRequest( counterparty = it.counterparty, paymentRequestId = it.paymentRequestId, ) - }.onFailure { error -> - // Acceptance may already be committed. Reconcile before discarding its owner. - val uncertain = when (error) { - is PaykitException.Transport, - is PaykitException.Storage, - is PaykitException.Identity, - is PaykitException.ConcurrentUpdate, - is PaykitException.SharedStateBusy -> true - else -> false - } - if (!alreadySaved && !uncertain) { - val remaining = presentationStore.removeAcceptedOneTimeIds(identity, setOf(it.id)) - if (isCurrentState(generation, identity)) acceptedOneTimeRequestIds = remaining - } - }.getOrThrow() - if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable + } }.getOrThrow() } }.onFailure { @@ -875,6 +853,55 @@ class PaykitPaymentRequestRepo @Inject constructor( } } + /** + * Accepts [request] through [accept] with this install as its owner, like [accept], for an acceptance that does not + * come from the request sheet: the Allowance accepts an automatically paid request. The request stays payable on + * this install if the payment that follows fails. + */ + suspend fun acceptOnThisInstall( + request: PaykitPaymentRequest, + accept: suspend () -> T, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + if (!request.requiresAcceptance) throw PaykitPaymentRequestError.RequestUnavailable + operationMutex.withLock { withAcceptanceIntent(request, accept) } + }.onFailure { + Logger.warn("Failed to accept an incoming Paykit payment request for this install", it, context = TAG) + } + } + + /** Saves this install as the owner of [request] before [operation] runs, and drops it on a definite failure. */ + private suspend fun withAcceptanceIntent(request: PaykitPaymentRequest, operation: suspend () -> T): T { + val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable + val generation = stateGeneration.get() + ensurePaymentAllowed(request, forExecution = false).getOrThrow() + val alreadySaved = request.id in presentationStore.loadAcceptedOneTimeIds(identity) + val acceptedIds = presentationStore.addAcceptedOneTimeId(identity, request.id) + requireCurrentState(generation, identity) + acceptedOneTimeRequestIds = acceptedIds + val result = runSuspendCatching { operation() }.onFailure { error -> + // Acceptance may already be committed. Reconcile before discarding its owner. + val uncertain = when (error) { + is PaykitException.Transport, + is PaykitException.Storage, + is PaykitException.Identity, + is PaykitException.ConcurrentUpdate, + is PaykitException.SharedStateBusy -> true + else -> false + } + if (!alreadySaved && !uncertain) { + val remaining = presentationStore.removeAcceptedOneTimeIds(identity, setOf(request.id)) + if (isCurrentState(generation, identity)) acceptedOneTimeRequestIds = remaining + } + }.getOrThrow() + requireCurrentState(generation, identity) + return result + } + + private fun requireCurrentState(generation: Long, identity: String) { + if (!isCurrentState(generation, identity)) throw PaykitPaymentRequestError.RequestUnavailable + } + suspend fun reject(request: PaykitPaymentRequest): Result = updateRequest( request = request, resultingState = PaymentRequestLifecycleState.REJECTED, diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 242730de80..cf7003ea69 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -396,6 +396,94 @@ 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]?.consumedPrivatePaymentListVersion + val prepared = paykitSdkService.prepareAndResolvePrivatePaymentRequest( + counterparty = publicKey, + paymentRequestId = request.paymentRequestId, + afterPrivatePaymentListVersion = consumedVersion, + ) + 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(publicKey), + ) + throw PaykitAllowanceError.PaymentListPending + } + val paymentListVersion = 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)?.copy(appId = it.appId) } + .filter { + it.methodId.rawValue in eligible && (it.methodId == MethodId.Bolt11 || it.methodId.isOnchain) + } + val payable = privatePayableEndpoints(candidates, publicKey, allowUsedOnchainAddress = false) + allowancePayment( + request = request, + payable = payable, + paymentListVersion = paymentListVersion, + ) + } + } + + private suspend fun allowancePayment( + request: PaykitPaymentRequest, + payable: List, + paymentListVersion: ULong, + ): PrivatePaykitAllowancePayment? = PublicPaykitRepo.payablePreferenceOrder + .mapNotNull { methodId -> payable.firstOrNull { it.methodId == methodId } } + .firstNotNullOfOrNull { allowancePayment(request, it, paymentListVersion) } + + private suspend fun allowancePayment( + request: PaykitPaymentRequest, + endpoint: Endpoint, + paymentListVersion: ULong, + ): PrivatePaykitAllowancePayment? { + val appId = endpoint.appId ?: return null + val context = PrivatePaykitPaymentContext( + paymentAppsByEndpoint = mapOf(endpoint.methodId.rawValue to appId), + paymentListVersion = paymentListVersion, + ) + if (endpoint.methodId != MethodId.Bolt11) { + return PrivatePaykitAllowancePayment( + endpoint = endpoint, + appId = appId, + 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, + appId = appId, + 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 ce09246cba..a036abd1d4 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -88,6 +88,16 @@ data class PrivatePaykitPaymentContext( val paymentListVersion: ULong?, ) +/** The payee's current private endpoint for an automatic Allowance payment. */ +data class PrivatePaykitAllowancePayment( + val endpoint: Endpoint, + /** The payee Paykit app that owns [endpoint]; the payment proof names it. */ + val appId: String, + val context: PrivatePaykitPaymentContext, + val lightningPaymentHash: String?, + val lightningInvoiceHasAmount: Boolean, +) + @OptIn(ExperimentalTime::class) @Suppress("LongParameterList") @Singleton diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 9ad7184c48..61be68fb4d 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 androidx.annotation.VisibleForTesting +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.ContactRecord import com.synonym.paykit.ContactUpdate import com.synonym.paykit.EndpointSyncReport @@ -9,6 +18,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.PaykitAppCapabilities import com.synonym.paykit.PaykitException @@ -18,6 +28,12 @@ import com.synonym.paykit.PaykitProfileRecord 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.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole 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.ProfileResolution import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkyClientConfig @@ -807,6 +825,7 @@ class PaykitSdkService @Inject constructor( paymentEndpointIdentifier: String, proofJson: String, billingPeriod: PaykitBillingPeriod? = null, + allowanceId: String? = null, ): PaymentRequestRecord { isSetup.await() return operationLock.withLock { @@ -818,7 +837,7 @@ class PaykitSdkService @Inject constructor( billingPeriod = billingPeriod?.sdkValue, paymentAppId = paymentAppId, paymentEndpointIdentifier = paymentEndpointIdentifier, - allowanceId = null, + allowanceId = allowanceId, conversionQuoteId = null, proof = PrivateJsonObject(proofJson), ), @@ -827,6 +846,183 @@ class PaykitSdkService @Inject constructor( } } + suspend fun listAllowances(filter: AllowanceFilter): List { + isSetup.await() + return operationLock.withLock { + refreshPaykitKey() + handle().listAllowances(filter) + } + } + + suspend fun proposeAllowance( + counterparty: String, + localRole: AllowanceLocalRole, + terms: AllowanceTerms, + ): AllowanceRecord { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + handle.proposeAllowance(counterparty, localRole, terms) + } + } + } + + suspend fun acceptAllowance( + counterparty: String, + allowanceId: String, + ): AllowanceRecord { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + handle.acceptAllowance(counterparty, allowanceId) + } + } + } + + suspend fun rejectAllowance( + counterparty: String, + allowanceId: String, + ): AllowanceRecord { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + handle.rejectAllowance(counterparty, allowanceId) + } + } + } + + suspend fun endAllowance( + counterparty: String, + allowanceId: String, + ): AllowanceRecord { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + handle.endAllowance(counterparty, allowanceId) + } + } + } + + suspend fun receivePrivateMessages( + counterparty: String, + ): PrivateStreamIntakeReport { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + handle.receivePrivateMessages(counterparty) + } + } + } + + suspend fun processOutboundPrivateMessages( + counterparty: String, + ): OutboundPrivateSendReport { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + handle.processOutboundPrivateMessages(counterparty) + } + } + } + + suspend fun allowanceAccountingState(): AllowanceAccountingState? { + isSetup.await() + return operationLock.withLock { + refreshPaykitKey() + handle().allowanceAccountingState() + } + } + + suspend fun reconcileAllowanceAccounting( + reconciliation: AllowanceAccountingReconciliation, + ): AllowanceAccountingState { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + handle.reconcileAllowanceAccounting(reconciliation) + } + } + } + + suspend fun evaluateAllowanceCandidates( + scope: PaymentRequestScope, + trustedTime: String, + ): List { + isSetup.await() + return operationLock.withLock { + refreshPaykitKey() + handle().evaluateAllowanceCandidates(scope, trustedTime) + } + } + + suspend fun acceptPaymentRequestAutomatically( + scope: PaymentRequestScope, + selection: AllowanceSelectionInput, + checks: PaymentExecutionChecks, + ): AllowanceAssociationRecord { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + handle.acceptPaymentRequestAutomatically(scope, selection, checks) + } + } + } + + suspend fun reserveAutomaticPayment( + occurrence: PaymentOccurrence, + expectedAssociationRevision: ULong, + checks: PaymentExecutionChecks, + ): PaymentAttemptDecision { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + handle.reserveAutomaticPayment(occurrence, expectedAssociationRevision, checks) + } + } + } + + suspend fun reserveManualPayment( + occurrence: PaymentOccurrence, + checks: PaymentExecutionChecks, + ): PaymentAttemptDecision { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + handle.reserveManualPayment(occurrence, checks) + } + } + } + + suspend fun beginPaymentExecution( + attemptId: String, + checks: PaymentExecutionChecks, + ): PaymentAttemptDecision { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + handle.beginPaymentExecution(attemptId, checks) + } + } + } + + suspend fun recordPaymentOutcome(report: PaymentOutcomeReport): PaymentAttemptRecord { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + handle.recordPaymentOutcome(report) + } + } + } + + suspend fun markPaymentManualOnly(occurrence: PaymentOccurrence): PaymentOccurrenceRecord { + isSetup.await() + return operationLock.withLock { + withStateRevisionTracking { handle -> + handle.markPaymentManualOnly(occurrence) + } + } + } + suspend fun rejectPaymentRequest( counterparty: String, paymentRequestId: String, @@ -916,6 +1112,7 @@ class PaykitSdkService @Inject constructor( ): PaykitPublicContactPaymentResolution { isSetup.await() val resolution = operationLock.withLock { + refreshPaykitKey() handle().resolvePublicContactPayment(counterparty, amount = null) } return resolution.toPaykitPublicContactPaymentResolution() diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index db61bc3a9f..ce15615f9f 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -123,6 +123,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 @@ -528,7 +529,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 = { @@ -580,6 +581,8 @@ fun ContentView( is Sheet.Subscription -> SubscriptionSheet(appViewModel, sheet.route) + is Sheet.Allowance -> AllowanceSheet(appViewModel, sheet.route) + 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 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 90a5523e75..60f79b38e0 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 @@ -197,6 +197,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() @@ -211,6 +212,7 @@ fun PaymentRequestsScreen( contacts = contacts.toImmutableList(), subscriptions = subscriptions.toImmutableList(), dismissingRequestIds = dismissingRequestIds.toImmutableSet(), + autoPaidRequestIds = autoPaidRequestIds, canRequestPayment = targets.isNotEmpty(), onBack = onBack, onRequestPayment = onRequestPayment, @@ -238,6 +240,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 @@ -331,9 +334,12 @@ internal fun PaymentRequestsContent( request = request, contact = contacts.contactFor(request), compactSubtitle = if (request.hasPaymentEvidence) { - subscriptions.nameFor(request) - ?: request.note?.takeIf(String::isNotBlank) - ?: paymentRequestDate(request) + paymentRequestHistorySubtitle( + subtitle = subscriptions.nameFor(request) + ?: request.note?.takeIf(String::isNotBlank) + ?: paymentRequestDate(request), + isAutoPaid = request.id in autoPaidRequestIds, + ) } else { paymentRequestStatus(request) }, @@ -471,6 +477,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 26ee057e24..5611a993e5 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 @@ -120,6 +122,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() @@ -143,6 +146,7 @@ fun SubscriptionsScreen( }, onCreateSubscription = onCreateSubscription, paymentsContent = { topPadding -> + val autoPaidRequestIds by allowancesViewModel.autoPaidRequestIds.collectAsStateWithLifecycle() PaymentRequestsScreen( appViewModel = appViewModel, onBack = onBack, @@ -150,6 +154,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, ) }, ) @@ -167,6 +183,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) } @@ -195,6 +212,8 @@ internal fun SubscriptionsContent( ) { if (selectedTab == SubscriptionTab.Payments) { paymentsContent(headerHeight) + } else if (selectedTab == SubscriptionTab.Allowances) { + allowancesContent(headerHeight) } else if (!hasVisibleSubscriptions) { SubscriptionEmptyState( Modifier @@ -322,7 +341,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, @@ -333,12 +352,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/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 7d4af28de8..0fa36e8f3c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -152,6 +152,9 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LnurlPayInvoiceMismatchError import to.bitkit.repositories.MethodId import to.bitkit.repositories.NodeEventUpdate +import to.bitkit.repositories.PaykitAllowanceError +import to.bitkit.repositories.PaykitAllowanceEvent +import to.bitkit.repositories.PaykitAllowanceRepo import to.bitkit.repositories.PaykitOnchainPaymentProofResolution import to.bitkit.repositories.PaykitPaymentProofKind import to.bitkit.repositories.PaykitPaymentProofRepo @@ -188,9 +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 @@ -258,6 +264,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 refreshContactPaykitLink: RefreshContactPaykitLinkUseCase, @@ -569,6 +576,7 @@ class AppViewModel @Inject constructor( observePaykitPaymentRequestConnectivity() observeIncomingPaykitPaymentRequests() observePaykitOnchainPaymentResolution() + observePaykitAllowanceEvents() observeSendEvents() viewModelScope.launch { checkCriticalAppUpdate() @@ -718,6 +726,7 @@ class AppViewModel @Inject constructor( preserveRequestedPaymentRequest = paymentRequestIdentity == null, ) paymentRequestIdentity = null + paykitAllowanceRepo.deactivate() try { paykitPaymentRequestRepo.clear() } finally { @@ -739,6 +748,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 refreshPrivateOnlyPaykitApp("contact sync") @@ -871,10 +881,63 @@ class AppViewModel @Inject constructor( if (refreshMaintenance) paykitPaymentProofRepo.reconcile() paykitPaymentRequestRepo.refresh(syncPrivateMessages = refreshMaintenance).onSuccess { activityRepo.backfillPaykitContacts() + 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 -> notifyAllowanceEvent( + 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 -> notifyAllowanceEvent( + 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 + } + } + + /** + * 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" + + 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( @@ -996,12 +1059,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 @@ -4075,12 +4145,19 @@ 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( @@ -4088,15 +4165,36 @@ class AppViewModel @Inject constructor( preparedPaymentProofRequest, contactPaymentContext, 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 @@ -4119,6 +4217,7 @@ class AppViewModel @Inject constructor( onBroadcast = { txId -> proofRequest = null completeOnchainPaymentProofInBackground(incomingPaymentRequest, txId, proofPreparation) + finishManualAllowancePayment(allowanceAttemptId, succeeded = true, transactionId = txId) }, ).onSuccess { txId -> Logger.info("Onchain send result txid: $txId", context = TAG) @@ -4142,6 +4241,7 @@ class AppViewModel @Inject constructor( incomingPaymentRequest = incomingPaymentRequest, preparedPaymentProofRequest = proofRequest, contactPaymentContext = contactPaymentContext, + allowanceAttemptId = allowanceAttemptId, ) } } @@ -4153,6 +4253,7 @@ class AppViewModel @Inject constructor( incomingPaymentRequest: PaykitPaymentRequest?, preparedPaymentProofRequest: PaykitPaymentRequest?, contactPaymentContext: ContactPaymentContext?, + allowanceAttemptId: String? = null, ) { val amount = _sendUiState.value.amount if ( @@ -4161,6 +4262,7 @@ class AppViewModel @Inject constructor( !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 } @@ -4178,6 +4280,7 @@ class AppViewModel @Inject constructor( if (paymentProofStarted) { incomingPaymentRequest?.let { paykitPaymentProofRepo.failOnchainPayment(it) } } + finishManualAllowancePayment(allowanceAttemptId, succeeded = false) releasePrivatePaymentListIfNeeded(contactPaymentContext) cancelPaymentProofPreparation(preparedPaymentProofRequest) Logger.error("Error sending onchain payment", error, context = TAG) @@ -4199,6 +4302,7 @@ class AppViewModel @Inject constructor( preparedPaymentProofRequest: PaykitPaymentRequest?, contactPaymentContext: ContactPaymentContext?, amount: ULong, + allowanceAttemptId: String?, ) { val decodedInvoice = requireNotNull(_sendUiState.value.decodedInvoice) val paymentAmount = if (decodedInvoice.amountSatoshis > 0uL) null else amount @@ -4227,6 +4331,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) } var authorizationError: Throwable? = null val result = sendLightning(decodedInvoice.bolt11, paymentAmount) { @@ -4270,6 +4376,7 @@ class AppViewModel @Inject constructor( } releasePrivatePaymentListIfNeeded(contactPaymentContext) 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/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 41669bb81d..8eae62b414 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1078,6 +1078,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/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt new file mode 100644 index 0000000000..58a7d8c751 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -0,0 +1,882 @@ +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.PaymentRequestRecord +import com.synonym.paykit.PaymentRequestScope +import com.synonym.paykit.PrivateStreamIntakeReport +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import org.junit.After +import org.junit.Assume.assumeTrue +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.ALLOWANCE_ID +import to.bitkit.repositories.PaykitAllowanceLocalState.JournalEntry +import to.bitkit.repositories.PaykitAllowanceLocalState.Stage +import to.bitkit.services.PaykitSdkService +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import to.bitkit.utils.SubscriptionClockOffset +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 const val PAYMENT_APP_ID = "bitkit" + 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.claimPaymentRequestForExecution(any(), any())).thenReturn(mock()) + whenever(sdk.processOutboundPrivateMessages(any())) + .thenReturn(OutboundPrivateSendReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList())) + whenever(sdk.receivePrivateMessages(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.acceptOnThisInstall(any(), any())).doSuspendableAnswer { + runCatching { it.getArgument AllowanceAssociationRecord>(1).invoke() } + } + whenever(payer.consumePaymentList(any(), any())).thenReturn(Result.success(Unit)) + whenever(payer.prepareProof(any(), any(), any(), anyOrNull())).thenReturn(Result.success(Unit)) + whenever(payer.associateLightningPayment(any(), any(), any(), any())).thenReturn(Result.success(Unit)) + whenever(payer.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(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(payer).acceptOnThisInstall(eq(request), any()) + order.verify(sdk).claimPaymentRequestForExecution(request.counterparty, request.paymentRequestId) + order.verify(sdk).acceptPaymentRequestAutomatically(any(), any(), any()) + order.verify(sdk).reserveAutomaticPayment(any(), any(), any()) + order.verify(sdk).receivePrivateMessages(request.counterparty) + order.verify(sdk).beginPaymentExecution(eq(AUTOMATIC_ATTEMPT_ID), any()) + order.verify(payer).consumePaymentList(request.counterparty, lightningPayment().context) + order.verify(payer).prepareProof(request, fixtures.lightningIdentifier, PAYMENT_APP_ID, ALLOWANCE_ID) + order.verify(payer).associateLightningPayment( + request, + PAYMENT_HASH, + fixtures.lightningIdentifier, + PAYMENT_APP_ID, + ) + order.verify(payer).payLightning(eq(INVOICE), isNull()) + verify(sdk, never()).recordPaymentOutcome(any()) + assertEquals(listOf(PaykitAllowanceTime.format(fixtures.now)), evaluatedTrustedTimes) + assertEquals(listOf(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(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 `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"))) + + 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 `execution claim held by another app keeps the request manual before any acceptance`() = test { + whenever(sdk.claimPaymentRequestForExecution(any(), any())).doSuspendableAnswer { + error("Another app owns the execution claim") + } + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any()) + verify(sdk, never()).reserveAutomaticPayment(any(), any(), any()) + verify(payer, never()).payLightning(any(), anyOrNull()) + } + + @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(request.counterparty, 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(), 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(), 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, PAYMENT_APP_ID, 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, PAYMENT_APP_ID) + 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(), any()) + } + + @Test + fun `on-chain payment completes when its caller is cancelled during the send`() = test { + whenever(payer.resolve(any(), any())).thenReturn(Result.success(onchainPayment())) + val sendResult = CompletableDeferred>() + whenever(payer.payOnchain(any(), any())).doSuspendableAnswer { sendResult.await() } + val request = fixtures.paymentRequest() + + val caller = launch { sut.autoPay(request, listOf(fixtures.allowance()), identity) } + assertEquals(Stage.SENDING, sut.localState(identity).journal.single().stage) + caller.cancel() + sendResult.complete(Result.success(TXID)) + caller.join() + + verify(payer).completeOnchainPayment(request, TXID, fixtures.onchainIdentifier, PAYMENT_APP_ID) + assertEquals(listOf(PaymentOutcomeReport(AUTOMATIC_ATTEMPT_ID, PaymentOutcome.SUCCEEDED)), recordedOutcomes) + assertEquals(Stage.SUCCEEDED, sut.localState(identity).journal.single().stage) + } + + // 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) + } + + @Test + fun `admission ignores the subscription clock offset`() = test { + offsetSubscriptionClock(days = 400) + accountingState = stateNearTheMonthlyCap() + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals( + PaykitAllowanceAutoPayResult.MANUAL, + result, + "September's attempts must count; the offset would move the window a year ahead", + ) + assertEquals(listOf(PaykitAllowanceTime.format(fixtures.now)), evaluatedTrustedTimes) + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any()) + } + + // endregion + + // region Helpers + + private fun offsetSubscriptionClock(days: Int) { + SubscriptionClockOffset.setOffsetDays(days) + assumeTrue( + "The subscription clock offset is unavailable in this build", + SubscriptionClockOffset.offsetDays == days, + ) + } + + @After + fun resetSubscriptionClockOffset() { + SubscriptionClockOffset.setOffsetDays(0) + } + + private fun TestScope.collectEvents() { + backgroundScope.launch { sut.events.collect { events += it } } + } + + private fun candidate(blocked: AllowanceAccountingBlock? = null) = AllowanceCandidate( + allowanceId = ALLOWANCE_ID, + eligiblePaymentEndpointIdentifiers = listOf(PaykitAllowanceFixtures.lightningIdentifier), + blocked = blocked, + ) + + private fun lightningPayment() = PrivatePaykitAllowancePayment( + endpoint = Endpoint( + methodId = MethodId.Bolt11, + value = INVOICE, + rawPayload = """{"value":"$INVOICE"}""", + ), + appId = PAYMENT_APP_ID, + context = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to PAYMENT_APP_ID), 3uL), + lightningPaymentHash = PAYMENT_HASH, + lightningInvoiceHasAmount = true, + ) + + private fun onchainPayment() = PrivatePaykitAllowancePayment( + endpoint = Endpoint( + methodId = MethodId.P2wpkh, + value = ONCHAIN_ADDRESS, + rawPayload = """{"value":"$ONCHAIN_ADDRESS"}""", + ), + appId = PAYMENT_APP_ID, + context = PrivatePaykitPaymentContext(mapOf(MethodId.P2wpkh.rawValue to PAYMENT_APP_ID), 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 = 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..1c186bfa00 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt @@ -0,0 +1,514 @@ +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.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.After +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.PaymentFailureReason +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +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.ALLOWANCE_ID +import to.bitkit.repositories.PaykitAllowanceFixtures.SECOND_ALLOWANCE_ID +import to.bitkit.repositories.PaykitAllowanceLocalState.Stage +import to.bitkit.services.PaykitSdkService +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.SubscriptionClockOffset +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())) + .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 list one grant per contact with the grant's limits`() = test { + records = listOf( + fixtures.record(allowanceId = ALLOWANCE_ID), + fixtures.record(allowanceId = "allowance-other", counterparty = fixtures.otherCounterpartyKey), + fixtures.record(allowanceId = "allowance-invalid", historyStatus = AllowanceHistoryStatus.INVALID), + ) + localState = PaykitAllowanceLocalState(groups = listOf(group(ALLOWANCE_ID))) + + sut.activate(identity) + + val entries = sut.entries.value + assertEquals(listOf("group-1", "allowance-other"), entries.map { it.id }) + val grant = entries.first() + assertEquals(listOf(ALLOWANCE_ID), grant.allowances.map { it.allowanceId }) + assertEquals(ALLOWANCE_ID, grant.primary.allowanceId) + assertEquals(fixtures.limits, grant.limits) + assertEquals(fixtures.counterpartyKey, grant.counterparty) + assertEquals(PaykitAllowance.Role.ALLOWER, grant.role) + assertEquals(5_000uL, grant.perPaymentMaxSats) + assertEquals(50_000uL, grant.monthlyLimitSats) + assertEquals(PaykitAllowance.Status.ACTIVE, grant.status(fixtures.now)) + assertNull(entries.last().limits) + assertEquals(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 = ALLOWANCE_ID)) + val paid = fixtures.paymentRequest(id = "paid") + val failed = fixtures.paymentRequest(id = "failed") + val secondPaid = fixtures.paymentRequest(id = "second") + localState = PaykitAllowanceLocalState( + groups = listOf(group(ALLOWANCE_ID)), + journal = listOf( + journalEntry("a", paid, ALLOWANCE_ID, 1_000uL, Stage.SUCCEEDED), + journalEntry("b", secondPaid, ALLOWANCE_ID, 2_000uL, Stage.SUCCEEDED), + journalEntry("c", failed, ALLOWANCE_ID, 5_000uL, Stage.FAILED), + journalEntry("d", fixtures.paymentRequest(id = "manual"), null, 7_000uL, Stage.SUCCEEDED, false), + ), + ) + + 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 for the request's contact identity`() = 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))) + + 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 `coverage ignores the subscription clock offset`() = test { + SubscriptionClockOffset.setOffsetDays(400) + assumeTrue( + "The subscription clock offset is unavailable in this build", + SubscriptionClockOffset.offsetDays == 400, + ) + records = listOf(fixtures.record(terms = fixtures.terms(expiresAt = (fixtures.now + 30.days).toString()))) + + sut.activate(identity) + + assertTrue(sut.coversRequest(fixtures.paymentRequest()), "The allowance still has 30 days in real time") + assertFalse(sut.coversRequest(fixtures.paymentRequest(counterparty = fixtures.otherCounterpartyKey))) + } + + @After + fun resetSubscriptionClockOffset() { + SubscriptionClockOffset.setOffsetDays(0) + } + + @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 terms to the linked contact and records one entry`() = test { + whenever(sdk.linkedPeers()).thenReturn( + listOf( + linkedPeer(fixtures.otherCounterpartyKey), + linkedPeer(fixtures.counterpartyKey), + ), + ) + whenever(sdk.proposeAllowance(any(), any(), any())).doSuspendableAnswer { + fixtures.record( + allowanceId = ALLOWANCE_ID, + 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()) + verify(sdk).proposeAllowance(fixtures.counterpartyKey, AllowanceLocalRole.ALLOWER, terms) + verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey) + assertEquals( + listOf(fixtures.septemberAnchor to PaykitAllowanceRepo.allowedPaymentEndpointIdentifiers()), + proposedTerms, + ) + val group = localState.groups.single() + assertEquals(listOf(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 fails when the contact link is not up`() = test { + whenever(sdk.linkedPeers()) + .thenReturn(listOf(linkedPeer(fixtures.counterpartyKey, LinkedPeerState.LINKING))) + sut.activate(identity) + + val result = sut.propose(fixtures.counterpartyKey, fixtures.limits) + + assertIs(result.exceptionOrNull()) + verify(sdk, never()).proposeAllowance(any(), any(), any()) + assertTrue(localState.groups.isEmpty()) + } + + @Test + fun `an allowance stays one grant for the identity when the contact adds another app later`() = test { + // The grant is bound to the contact's identity, so one that links an app later is covered without a proposal. + records = listOf(fixtures.record(allowanceId = ALLOWANCE_ID)) + localState = PaykitAllowanceLocalState(groups = listOf(group(ALLOWANCE_ID))) + whenever(sdk.linkedPeers()).thenReturn(listOf(linkedPeer(fixtures.counterpartyKey))) + sut.activate(identity) + + sut.refresh() + + verify(sdk, never()).proposeAllowance(any(), any(), any()) + assertTrue(sut.coversRequest(fixtures.paymentRequest())) + assertEquals(listOf(ALLOWANCE_ID), sut.entries.value.single().allowances.map { it.allowanceId }) + } + + @Test + fun `received proposal is presented once and accepting answers it`() = test { + val proposalRecord = receivedProposal() + records = listOf(proposalRecord) + whenever(sdk.acceptAllowance(any(), any())).thenReturn(proposalRecord) + sut.activate(identity) + + val proposal = checkNotNull(sut.proposalForPresentation()) + assertEquals(ALLOWANCE_ID, proposal.id) + sut.markProposalPresented(proposal.id) + assertNull(sut.proposalForPresentation()) + assertEquals(setOf(ALLOWANCE_ID), localState.presentedProposalIds) + + assertTrue(sut.accept(proposal.id).isSuccess) + + verify(sdk).acceptAllowance(fixtures.counterpartyKey, ALLOWANCE_ID) + verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey) + } + + @Test + fun `answering fails when nothing in the entry awaits an answer`() = test { + records = listOf(fixtures.record()) + sut.activate(identity) + + assertIs(sut.reject(ALLOWANCE_ID).exceptionOrNull()) + assertIs(sut.accept("missing").exceptionOrNull()) + verify(sdk, never()).rejectAllowance(any(), any()) + } + + @Test + fun `ending an entry ends its allowance and sends the end right away`() = test { + records = listOf(fixtures.record(allowanceId = ALLOWANCE_ID)) + localState = PaykitAllowanceLocalState(groups = listOf(group(ALLOWANCE_ID))) + whenever(sdk.endAllowance(any(), any())).thenReturn(records.last()) + sut.activate(identity) + + assertTrue(sut.end("group-1").isSuccess) + + verify(sdk).endAllowance(fixtures.counterpartyKey, ALLOWANCE_ID) + verify(sdk).processOutboundPrivateMessages(fixtures.counterpartyKey) + } + + // endregion + + // region Automatic payments + + @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) + 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 = SECOND_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 { + nodeReady() + 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 `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()))) + 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(), 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(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 nodeReady() = lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } + + 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, + state: LinkedPeerState = LinkedPeerState.LINKED, + ) = LinkedPeerRecord( + counterparty = publicKey, + state = state, + lastSyncAt = null, + lastPrivateReceiveAt = null, + failureCount = 0u, + localRecoveryAttemptId = null, + localRecoveryMarkerCreatedAt = null, + localRecoveryMarkerLastError = null, + remoteRecoveryAttemptId = null, + remoteRecoveryMarkerObservedAt = null, + ) + + // endregion +} 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..daf5442875 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt @@ -0,0 +1,688 @@ +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.After +import org.junit.Assume.assumeTrue +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.test.BaseUnitTest +import to.bitkit.utils.SubscriptionClockOffset +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock +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(PaykitAllowanceFixtures.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.SECOND_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.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"))) + } + + @Test + fun `status and capacity ignore the subscription clock offset`() { + SubscriptionClockOffset.setOffsetDays(365) + assumeTrue( + "The subscription clock offset is unavailable in this build", + SubscriptionClockOffset.offsetDays == 365, + ) + assertTrue(SubscriptionClockOffset.subscriptionNow() > Clock.System.now() + 364.days) + + 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(50_000uL, fixtures.usedSats(attempts)) + assertFalse(PaykitAllowanceCapacity.fits(1uL, allowance, attempts, fixtures.now)) + } + + @After + fun resetSubscriptionClockOffset() { + SubscriptionClockOffset.setOffsetDays(0) + } + + // endregion + + // region Grouping and terms + + @Test + fun `entry primary prefers an accepted allowance`() { + val first = fixtures.allowance(perPaymentMaxSats = 5_000uL) + val second = fixtures.allowance( + allowanceId = PaykitAllowanceFixtures.SECOND_ALLOWANCE_ID, + perPaymentMaxSats = 1uL, + ) + + val entry = PaykitAllowanceEntry(id = "group", allowances = listOf(first, second), limits = fixtures.limits) + assertEquals(PaykitAllowanceFixtures.ALLOWANCE_ID, entry.primary.allowanceId) + assertEquals(5_000uL, entry.perPaymentMaxSats) + + val firstDeclined = first.copy(lifecycleState = AllowanceLifecycleState.REJECTED) + val acceptedSecond = PaykitAllowanceEntry(id = "g", allowances = listOf(firstDeclined, second), limits = null) + assertEquals(PaykitAllowanceFixtures.SECOND_ALLOWANCE_ID, acceptedSecond.primary.allowanceId) + + val bothProposed = listOf( + first.copy(lifecycleState = AllowanceLifecycleState.PROPOSED), + second.copy(lifecycleState = AllowanceLifecycleState.PROPOSED), + ) + val proposed = PaykitAllowanceEntry(id = "p", allowances = bothProposed, limits = null) + assertEquals(PaykitAllowanceFixtures.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.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.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.ALLOWANCE_ID)) + + stored = "{not json" + assertEquals(PaykitAllowanceLocalState(), store.load(fixtures.identityKey)) + } + + // endregion +} + +/** Shared builders for the Allowance suites. */ +internal object PaykitAllowanceFixtures { + const val ALLOWANCE_ID = "allowance-1" + const val SECOND_ALLOWANCE_ID = "allowance-second" + 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 = ALLOWANCE_ID, + counterparty: String = counterpartyKey, + 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, + 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 = ALLOWANCE_ID, + counterparty: String = counterpartyKey, + 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, 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 = ALLOWANCE_ID, + sats: ULong, + at: String, + isLive: Boolean = true, + ) = PaykitAllowanceCapacity.Attempt(allowanceId, sats, Instant.parse(at), isLive) + + fun usedSats(attempts: List): ULong = + PaykitAllowanceCapacity.usedSats(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? = 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, + counterparty = counterpartyKey, + paymentRequestId = paymentRequestId, + ) + + fun occurrence( + requestId: String, + attempts: List, + allowanceId: String? = 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, + amountValue: String = "0.00001", + amountSats: ULong = 1_000uL, + createdAt: String = "2026-09-24T11:00:00Z", + ) = PaykitPaymentRequest( + paymentRequestId = id, + counterparty = counterparty, + 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 4c0e5081f3..39f3039cc0 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) } @@ -120,7 +121,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() @@ -142,6 +143,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = endpointCaptor.capture(), proofJson = proofCaptor.capture(), billingPeriod = isNull(), + allowanceId = isNull(), ) assertEquals(MethodId.Bolt11.rawValue, endpointCaptor.lastValue) assertEquals( @@ -165,7 +167,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)) @@ -179,6 +181,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 }) @@ -199,7 +202,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, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() @@ -217,6 +221,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = any(), proofJson = proofCaptor.capture(), billingPeriod = isNull(), + allowanceId = isNull(), ) assertEquals( """{"data":"$PREIMAGE","type":"${PaykitPaymentProofKind.Lightning.type}"}""", @@ -225,18 +230,46 @@ 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, "bitkit", PaykitPaymentProofKind.Lightning, ALLOWANCE_ID) + .getOrThrow() + sut.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() + assertEquals(ALLOWANCE_ID, storedProofs.single().allowanceId) + sut.completeLightningPayment(PAYMENT_HASH, PREIMAGE) + + verify(paykitSdkService).submitPaymentProof( + counterparty = any(), + paymentRequestId = any(), + paymentAppId = eq("bitkit"), + 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, "bitkit").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()) } @@ -250,7 +283,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 @@ -274,7 +307,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 @@ -287,7 +320,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 @@ -301,7 +334,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() @@ -327,7 +361,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 @@ -359,7 +401,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, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() @@ -376,6 +419,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { endpointCaptor.capture(), proofCaptor.capture(), isNull(), + isNull(), ) assertEquals(MethodId.P2wpkh.rawValue, endpointCaptor.firstValue) assertEquals( @@ -456,12 +500,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, "bitkit") - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(endpoint), any(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(endpoint), any(), isNull(), isNull()) assertTrue(storedProofs.isEmpty()) } @@ -474,7 +519,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, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() @@ -489,6 +535,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = any(), proofJson = any(), billingPeriod = periodCaptor.capture(), + allowanceId = isNull(), ) assertEquals(period, periodCaptor.firstValue) } @@ -514,14 +561,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, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").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 @@ -562,7 +610,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, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() @@ -570,7 +619,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { shouldFailNextSave = true repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) assertTrue(storedProofs.isEmpty()) } @@ -580,7 +629,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() @@ -590,7 +639,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") 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 @@ -599,7 +648,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, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() @@ -607,7 +657,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { shouldFailProofRemoval = true repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, "bitkit") - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull()) assertEquals(txid, storedProofs.single().proofData) } @@ -618,7 +668,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, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() @@ -635,6 +686,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = endpointCaptor.capture(), proofJson = proofCaptor.capture(), billingPeriod = isNull(), + allowanceId = isNull(), ) assertEquals(endpoint, endpointCaptor.firstValue) assertEquals( @@ -650,7 +702,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, @@ -674,6 +727,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentEndpointIdentifier = eq(MethodId.P2wpkh.rawValue), proofJson = proofCaptor.capture(), billingPeriod = isNull(), + allowanceId = isNull(), ) assertTrue(proofCaptor.firstValue.contains(txid)) } @@ -689,7 +743,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)) @@ -730,7 +784,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/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 2dd788dabe..41427249a2 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -569,6 +569,41 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) } + @Test + fun `automatic acceptance saves this install as owner before it runs and keeps the request payable here`() = test { + val proposed = paymentRequestRecord() + val accepted = proposed.copy(state = PaymentRequestLifecycleState.ACCEPTED) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(proposed)) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + val requestId = PaykitPaymentRequestId(PAYMENT_REQUEST_ID, COUNTERPARTY) + + val result = sut.acceptOnThisInstall(request) { + verify(presentationStore).addAcceptedOneTimeId(LOCAL_IDENTITY, requestId) + "accepted" + } + + assertEquals("accepted", result.getOrThrow()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(accepted)) + sut.refresh().getOrThrow() + sut.ensurePaymentAllowed(request).getOrThrow() + } + + @Test + fun `failed automatic acceptance drops this install as owner`() = test { + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(paymentRequestRecord())) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + + val result = sut.acceptOnThisInstall(request) { error("claimed by another app") } + + assertTrue(result.isFailure) + verify(presentationStore).removeAcceptedOneTimeIds( + LOCAL_IDENTITY, + setOf(PaykitPaymentRequestId(PAYMENT_REQUEST_ID, COUNTERPARTY)), + ) + } + @Test fun `local acceptance survives refresh and reconnect without accepting twice`() = test { val proposed = paymentRequestRecord() 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..e3532c7372 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt @@ -0,0 +1,259 @@ +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.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.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.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 PAYMENT_APP_ID = "bitkit" + 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).copy(appId = PAYMENT_APP_ID), + appId = PAYMENT_APP_ID, + context = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to PAYMENT_APP_ID), 7uL), + lightningPaymentHash = PAYMENT_HASH.toHex(), + lightningInvoiceHasAmount = true, + ), + payment, + ) + verify(paykitSdkService).prepareAndResolvePrivatePaymentRequest( + eq(CONTACT_KEY), + eq(request.paymentRequestId), + eq(null), + ) + } + + @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 `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)) + + assertNull(sut.resolveAllowancePayment(expired, eligible(MethodId.Bolt11)).getOrThrow()) + verify(paykitSdkService, never()).prepareAndResolvePrivatePaymentRequest(any(), any(), anyOrNull()) + } + + private suspend fun stubResolution( + vararg endpoints: PaykitResolvedPaymentEndpoint, + version: ULong? = 7uL, + status: PrivatePaymentResolutionStatus = PrivatePaymentResolutionStatus.PAYABLE, + ) { + whenever(paykitSdkService.prepareAndResolvePrivatePaymentRequest(any(), any(), anyOrNull())) + .thenReturn( + PaykitPreparedPrivateContactPayment( + resolution = PaykitPrivateContactPaymentResolution( + status = status, + 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( + appId = PAYMENT_APP_ID, + identifier = methodId.rawValue, + payload = payload(value), + ) + + private fun paymentRequest(accepted: List = eligible(MethodId.Bolt11, MethodId.P2wpkh)) = + PaykitPaymentRequest( + paymentRequestId = "request-id", + counterparty = CONTACT_KEY, + amountValue = "0.000025", + amountSats = 2_500uL, + expiresAt = Instant.fromEpochSeconds(NOW_SECONDS + 60), + acceptedPaymentEndpointIdentifiers = accepted, + ) +} 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..ecc88ce7f8 --- /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)) + + 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, "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/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index cd7e152fe6..c435ea0681 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -136,6 +136,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 @@ -236,6 +237,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() @@ -421,6 +423,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(), any()) }.thenReturn(Result.success(Unit)) whenever { paykitPaymentProofRepo.associateLightningPayment(any(), any(), any(), eq("bitkit")) @@ -516,6 +523,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { publicPaykitRepo = publicPaykitRepo, privatePaykitRepo = privatePaykitRepo, paykitPaymentRequestRepo = paykitPaymentRequestRepo, + paykitAllowanceRepo = paykitAllowanceRepo, paykitPaymentProofRepo = paykitPaymentProofRepo, paykitPaymentRequestDiagnostics = paykitPaymentRequestDiagnostics, refreshContactPaykitLink = refreshContactPaykitLink, 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. 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 | diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md new file mode 100644 index 0000000000..d2ccb4997e --- /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 over Paykit, 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..65a0221380 --- /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"; tap it and verify the detail sheet shows PAID AUTOMATICALLY "$0.00" (testTag "AllowancePaidSoFar") + 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" + +