Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/data/keychain/Keychain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ class Keychain @Inject constructor(
PAYKIT_USDT_PAYMENTS,
PAYKIT_PRESENTED_PAYMENT_REQUESTS,
PAYKIT_ALLOWANCE_STATE,
PAYKIT_ALLOWANCE_RESTORE_PAUSE,
PUBKY_SECRET_KEY,
SHARED_PUBKY_SOURCE,
}
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/repositories/BackupRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1116,6 +1116,8 @@ class BackupRepo @Inject constructor(
}

private suspend fun restoreWalletBackup(dataBytes: ByteArray): Long {
// Automatic allowance payments wait for the user to resume them before any restored state is written.
paykitAllowanceStore.pauseAfterRestore()
keychain.upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, String(dataBytes))
val parsed = json.decodeFromString<WalletBackupV1>(String(dataBytes))
var restoredAttempt: OnchainSendAttempt? = null
Expand Down
14 changes: 13 additions & 1 deletion app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ data class PaykitAllowance(
val lastEventAt: Instant? = null,
/** The received proposal's private stream item; accepting it is bound to this item. */
val proposalStreamItemId: ULong? = null,
/**
* True when the terms carry a limit beyond the per-payment cap and the first monthly amount limit, such as a
* lifetime total, a payment count or another period. This wallet does not check those itself, so requests under
* this Allowance stay on the manual flow.
*/
val hasUncheckedLimits: Boolean = false,
) {
@Serializable
data class Id(
Expand Down Expand Up @@ -115,7 +121,12 @@ data class PaykitAllowance(
}
val terms = record.terms ?: return null
if (terms.asset() != PaykitIssuerInterop.BITCOIN_ASSET) return null
val monthly = terms.periodLimits().firstOrNull { isMonthly(it.period()) }
val periodLimits = terms.periodLimits()
val monthlyIndex = periodLimits.indexOfFirst { isMonthly(it.period()) }
val monthly = periodLimits.getOrNull(monthlyIndex)
val hasUncheckedLimits = terms.lifetimeAmountLimit() != null ||
monthly?.paymentCountLimit() != null ||
periodLimits.indices.any { it != monthlyIndex }
val perPaymentMaxSats = terms.perPaymentAmount()?.let { satsFromBitcoinAmount(it.maximum()) }
val monthlyLimitSats = monthly?.amountLimit()?.let(::satsFromBitcoinAmount)
val monthlyAnchor = monthly?.period()?.anchor()?.let(PaykitAllowanceTime::parse)
Expand All @@ -136,6 +147,7 @@ data class PaykitAllowance(
allowedPaymentEndpointIdentifiers = terms.allowedPaymentEndpointIdentifiers(),
lastEventAt = record.lastEventAt?.let(PaykitAllowanceTime::parse),
proposalStreamItemId = record.proposalStreamItemId,
hasUncheckedLimits = hasUncheckedLimits,
)
}

Expand Down
125 changes: 79 additions & 46 deletions app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -309,10 +309,14 @@ class PaykitAllowanceExecutor @Inject constructor(

/** True while the ledger read is older than one this wallet has read, which keeps automatic payments paused. */
suspend fun isLedgerBehind(identity: String): Boolean = withContext(ioDispatcher) {
runSuspendCatching { isLedgerBehindOrThrow(identity) }.getOrDefault(false)
}

/** Like [isLedgerBehind], but a ledger that cannot be read throws instead of counting as current. */
suspend fun isLedgerBehindOrThrow(identity: String): Boolean = withContext(ioDispatcher) {
val floor = localState(identity).ledgerRevisionFloor ?: return@withContext false
runSuspendCatching { paykitSdkService.allowanceAccountingState() }
.map { ledger -> ledger == null || ledger.revision < floor }
.getOrDefault(false)
val ledger = paykitSdkService.allowanceAccountingState()
ledger == null || ledger.revision < floor
}

/**
Expand All @@ -324,6 +328,12 @@ class PaykitAllowanceExecutor @Inject constructor(
updateLocalStateDurably(identity) { it.copy(ledgerRevisionFloor = ledger?.revision) }
}

/** True after a wallet restore until [resumeAfterRestore]; every request asks for approval meanwhile. */
fun isPausedAfterRestore(): Boolean = store.isPausedAfterRestore()

/** Lets automatic payments run again after a wallet restore. The restored spends still count toward the limits. */
suspend fun resumeAfterRestore(): Unit = withContext(ioDispatcher) { store.resumeAfterRestore() }

// endregion

// region Ledger
Expand Down Expand Up @@ -430,8 +440,11 @@ class PaykitAllowanceExecutor @Inject constructor(
}

/**
* True when [request] fits no active Allowance of its contact, checked locally while the node cannot pay yet;
* such a request asks at once instead of waiting for channels, and raises Limit Reached.
* True when [request] cannot pay automatically, checked locally while the node cannot pay yet; such a request asks
* at once instead of waiting for channels. It waits only while an Allowance it may pay through fits it: one this
* wallet has not seen ended, without unchecked limits, within the per-payment cap and the monthly amount. Limit
* Reached is raised only when no Allowance this wallet has not seen ended fits at all, never while every covering
* Allowance has ended, automatic payments are paused or the ledger is behind this wallet.
*/
suspend fun classifyAboveLimit(
request: PaykitPaymentRequest,
Expand All @@ -440,25 +453,46 @@ class PaykitAllowanceExecutor @Inject constructor(
): Boolean = withContext(ioDispatcher) {
val amountSats = request.allowanceSats() ?: return@withContext false
val now = clock.now()
val matching = allowances.filter {
it.isAllower && it.status(now) == PaykitAllowance.Status.ACTIVE &&
PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty)
}
if (matching.isEmpty()) return@withContext false
val local = localState(identity)
val covering = activeAllowances(request, allowances, now)
if (covering.isEmpty()) return@withContext false
val matching = covering.filterNot { it.key in local.endedAllowanceIds }
// Allowances seen ended never pay again, so the request asks without waiting and without Limit Reached.
if (matching.isEmpty()) return@withContext true
// Paused automatic payments keep the request manual, without waiting for the node.
if (isPausedAfterRestore()) return@withContext true
val read = runSuspendCatching { paykitSdkService.allowanceAccountingState() }
// A ledger behind this wallet keeps the request manual, without waiting for the node.
if (read.isSuccess && runSuspendCatching { !acceptsLedger(read.getOrNull(), identity) }.getOrDefault(true)) {
return@withContext true
}
val attempts = read.getOrNull()?.let { PaykitAllowanceCapacity.attempts(it.history) }.orEmpty()
val localAttempts = PaykitAllowanceCapacity.attempts(localState(identity).automaticSpends)
if (matching.any { PaykitAllowanceCapacity.fits(amountSats, it, attempts, now, localAttempts) }) {
return@withContext false
}
notifyLimitReached(request, identity)
val fitting = fittingAllowances(amountSats, matching, read.getOrNull(), local, now)
if (fitting.any { !it.hasUncheckedLimits }) return@withContext false
if (fitting.isEmpty()) notifyLimitReached(request, identity)
true
}

private fun activeAllowances(
request: PaykitPaymentRequest,
allowances: List<PaykitAllowance>,
now: Instant,
): List<PaykitAllowance> = allowances.filter {
it.isAllower && it.status(now) == PaykitAllowance.Status.ACTIVE &&
PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty)
}

private fun fittingAllowances(
amountSats: ULong,
matching: List<PaykitAllowance>,
ledger: AllowanceAccountingState?,
local: PaykitAllowanceLocalState,
now: Instant,
): List<PaykitAllowance> {
val attempts = ledger?.let { PaykitAllowanceCapacity.attempts(it.history) }.orEmpty()
val localAttempts = PaykitAllowanceCapacity.attempts(local.automaticSpends)
return matching.filter { PaykitAllowanceCapacity.fits(amountSats, it, attempts, now, localAttempts) }
}

/** 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
Expand Down Expand Up @@ -589,6 +623,10 @@ class PaykitAllowanceExecutor @Inject constructor(

val activation = activeIdentity
try {
if (isPausedAfterRestore()) {
Logger.info("Kept an incoming request manual: paused after a wallet restore", context = TAG)
return@withContext PaykitAllowanceAutoPayResult.MANUAL
}
runSuspendCatching {
ensureReconciled(identity)
// Survive a cancelled caller: stopping after the acceptance would leave the request unpaid.
Expand Down Expand Up @@ -622,31 +660,10 @@ class PaykitAllowanceExecutor @Inject constructor(
val selectionTime = trustedTime(identity)
val candidates = paykitSdkService.evaluateAllowanceCandidates(scope, selectionTime)
val local = localState(identity)
val unblocked = candidates.filter { it.blocked == null }
val candidate = unblocked.firstOrNull {
PaykitAllowance.key(request.counterparty, it.allowanceId) !in local.endedAllowanceIds
}
if (candidate == null && unblocked.isNotEmpty()) {
Logger.info("Kept an incoming request manual: its allowance has ended", context = TAG)
return PaykitAllowanceAutoPayResult.MANUAL
}
val allowance = candidate?.let { eligible ->
allowances.firstOrNull {
it.allowanceId == eligible.allowanceId &&
PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty)
}
}
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)
// The SDK blocks a request above the per-payment limit before the capacity check below can notify.
val now = clock.now()
val isAbovePerPaymentMax = allowances.any { granted ->
granted.isAllower && granted.status(now) == PaykitAllowance.Status.ACTIVE &&
PubkyPublicKeyFormat.matches(granted.counterparty, request.counterparty) &&
granted.perPaymentMaxSats?.let { amountSats > it } == true
}
if (isAbovePerPaymentMax) notifyLimitReached(request, identity)
val now = clock.now()
val matching = activeAllowances(request, allowances, now).filterNot { it.key in local.endedAllowanceIds }
if (matching.isEmpty()) {
Logger.info("Kept an incoming request manual: no active allowance, or it has ended", context = TAG)
return PaykitAllowanceAutoPayResult.MANUAL
}

Expand All @@ -655,11 +672,27 @@ class PaykitAllowanceExecutor @Inject constructor(
Logger.info("Kept an incoming request manual: the ledger is behind this wallet", context = TAG)
return PaykitAllowanceAutoPayResult.MANUAL
}
val attempts = ledger?.let { PaykitAllowanceCapacity.attempts(it.history) }.orEmpty()
val localAttempts = PaykitAllowanceCapacity.attempts(local.automaticSpends)
if (!PaykitAllowanceCapacity.fits(amountSats, allowance, attempts, clock.now(), localAttempts)) {
Logger.info("Kept an incoming request manual: the allowance limit is reached", context = TAG)
notifyLimitReached(request, identity)
val fitting = fittingAllowances(amountSats, matching, ledger, local, now)
val checked = fitting.filterNot { it.hasUncheckedLimits }
val candidate = candidates.firstOrNull { eligible ->
eligible.blocked == null && checked.any { it.allowanceId == eligible.allowanceId }
}
if (candidate == null) {
val reasons = candidates.mapNotNull { it.blocked }
when {
fitting.isEmpty() -> {
Logger.info("Kept an incoming request manual: the allowance limit is reached", context = TAG)
notifyLimitReached(request, identity)
}
checked.isEmpty() -> Logger.info(
"Kept an incoming request manual: it fits only allowances with unchecked limits",
context = TAG,
)
else -> Logger.info(
"Kept an incoming request manual: no eligible allowance, blocked by '$reasons'",
context = TAG,
)
}
return PaykitAllowanceAutoPayResult.MANUAL
}

Expand Down
57 changes: 49 additions & 8 deletions app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ sealed interface PaykitAllowanceEvent {
data object LedgerChanged : PaykitAllowanceEvent
}

/** Why automatic payments wait for the user to resume them; every covered request asks for approval meanwhile. */
enum class PaykitAllowancePause {
/** The allowance ledger is behind one this wallet has read. */
LEDGER_BEHIND,

/** The wallet was restored from a backup. */
RESTORED,
}

enum class PaykitAllowanceAutoPayResult {
/** No accepted Allowance covers the request's contact, or the request is already being paid. */
NOT_COVERED,
Expand Down Expand Up @@ -98,6 +107,9 @@ class PaykitAllowanceRepo @Inject constructor(
/** A request created up to this long before its Allowance was accepted still counts as created after it. */
val ACCEPTANCE_CLOCK_TOLERANCE = 30.seconds

/** Lifecycle states of an allowance that pays, or may come to pay, automatically. */
private val PAYABLE_STATES = setOf(AllowanceLifecycleState.PROPOSED, AllowanceLifecycleState.ACCEPTED)

/** Endpoints Bitkit pays automatically: bolt11 and every on-chain method of the network. */
fun allowedPaymentEndpointIdentifiers(network: Network = Env.network): List<String> =
(listOf(MethodId.Bolt11) + MethodId.entries.filter { it.isOnchain }).map { it.rawValueForNetwork(network) }
Expand Down Expand Up @@ -135,10 +147,15 @@ class PaykitAllowanceRepo @Inject constructor(
/** Sats paid automatically per entry id, for "Paid so far". */
val autoPaidSats: StateFlow<Map<String, ULong>> = _autoPaidSats.asStateFlow()

private val _isAutomaticPaymentPaused = MutableStateFlow(false)
private val _automaticPaymentPause = MutableStateFlow<PaykitAllowancePause?>(null)

/** True while the allowance ledger is behind one this wallet has read, until [resumeAutomaticPayments]. */
val isAutomaticPaymentPaused: StateFlow<Boolean> = _isAutomaticPaymentPaused.asStateFlow()
/**
* Why automatic payments are paused until [resumeAutomaticPayments], or null while no pause notice shows. A ledger
* behind this wallet comes first. A wallet restore pauses automatic payments on this device until Resume, but its
* notice shows only while the wallet pays through a proposed or accepted allowance, so an allowance created later
* on a restored wallet starts paused.
*/
val automaticPaymentPause: StateFlow<PaykitAllowancePause?> = _automaticPaymentPause.asStateFlow()

val events: SharedFlow<PaykitAllowanceEvent> = executor.events

Expand Down Expand Up @@ -224,7 +241,7 @@ class PaykitAllowanceRepo @Inject constructor(
manualRequestSignatures.clear()
_offersToAnnounce.update { emptyList() }
announcedOfferKeys.clear()
_isAutomaticPaymentPaused.update { false }
_automaticPaymentPause.update { null }
publish(emptyList(), PaykitAllowanceLocalState())
}

Expand All @@ -234,16 +251,27 @@ class PaykitAllowanceRepo @Inject constructor(
refreshMutex.withLock {
val listing = listAllowances()
listing.onSuccess { executor.rememberEndedAllowances(identity, it) }
val isPaused = executor.isLedgerBehind(identity)
val allowances = listing.getOrDefault(_allowances.value)
val pause = automaticPaymentPause(identity, allowances)
if (activeIdentity == identity) {
_isAutomaticPaymentPaused.update { isPaused }
publish(listing.getOrDefault(_allowances.value), executor.localState(identity))
_automaticPaymentPause.update { pause }
publish(allowances, executor.localState(identity))
}
listing.map {}
}
}
}

private suspend fun automaticPaymentPause(
identity: String,
allowances: List<PaykitAllowance>,
): PaykitAllowancePause? = when {
executor.isLedgerBehind(identity) -> PaykitAllowancePause.LEDGER_BEHIND
executor.isPausedAfterRestore() && allowances.any { it.isAllower && it.lifecycleState in PAYABLE_STATES } ->
PaykitAllowancePause.RESTORED
else -> null
}

private suspend fun <T> whileUpdating(block: suspend () -> T): T {
val release = beginCoverageUpdate()
return try {
Expand Down Expand Up @@ -431,11 +459,24 @@ class PaykitAllowanceRepo @Inject constructor(
paykitSdkService.rejectAllowance(it.counterparty, it.allowanceId)
}

/** Resumes automatic payments from the ledger read now; see [PaykitAllowanceExecutor.acceptCurrentLedger]. */
/**
* Resumes automatic payments from the ledger read now and ends the pause after a wallet restore; see
* [PaykitAllowanceExecutor.acceptCurrentLedger]. Resuming from the restore notice first checks the ledger: one
* behind this wallet stays paused and shows its own notice, and one that cannot be read fails, so neither is
* accepted without the user seeing that notice.
*/
suspend fun resumeAutomaticPayments(): Result<Unit> = withContext(ioDispatcher) {
runSuspendCatching {
val identity = activeIdentity ?: throw PaykitAllowanceError.Unavailable
if (
_automaticPaymentPause.value == PaykitAllowancePause.RESTORED &&
executor.isLedgerBehindOrThrow(identity)
) {
refresh()
return@runSuspendCatching
}
executor.acceptCurrentLedger(identity)
executor.resumeAfterRestore()
executor.recover(identity)
refresh()
Unit
Expand Down
Loading
Loading