Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f5f3c97
fix: keep allowance history across a wallet restore
ovitrif Oct 27, 2026
24a46e6
fix: restore an allowance backup that omits optional fields
ovitrif Oct 27, 2026
da79536
fix: never back up unreadable allowance state as empty
ovitrif Oct 27, 2026
8c81493
fix: rebuild auto-paid totals from the allowance ledger on recovery
ovitrif Oct 27, 2026
652a95c
fix: count an automatic allowance payment once across journal and ledger
ovitrif Oct 27, 2026
2af0313
fix: let the node settle a restored allowance attempt
ovitrif Oct 27, 2026
2b45d6b
docs: add a journey for allowance history after a restore
ovitrif Oct 27, 2026
7b28fa0
Merge remote-tracking branch 'origin/fix/allowance-follow-ups' into f…
ovitrif Oct 27, 2026
411385d
fix: wait for the node before settling a restored allowance attempt
ovitrif Oct 27, 2026
ca13ac0
Merge remote-tracking branch 'origin/fix/allowance-follow-ups' into f…
ovitrif Oct 27, 2026
fbbfcfc
fix: keep a payment list's owner in the wallet backup
ovitrif Oct 27, 2026
ab457ea
Merge remote-tracking branch 'origin/fix/allowance-follow-ups' into f…
ovitrif Oct 27, 2026
67b3756
Merge remote-tracking branch 'origin/fix/allowance-follow-ups' into f…
ovitrif Oct 27, 2026
d6ba1c9
fix: write allowance journal stages in ascii
ovitrif Oct 28, 2026
bc5f8ef
Merge remote-tracking branch 'origin/fix/allowance-follow-ups' into f…
ovitrif Oct 28, 2026
f0eaf48
fix: journal a manual lightning hash before the send
ovitrif Oct 28, 2026
0f06291
Merge branch 'master' into fix/allowance-backup
ovitrif Oct 29, 2026
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
159 changes: 159 additions & 0 deletions app/src/main/java/to/bitkit/models/PaykitAllowanceStateBackup.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package to.bitkit.models

import kotlinx.serialization.Serializable
import to.bitkit.repositories.PaykitAllowance
import to.bitkit.repositories.PaykitAllowanceLimits
import to.bitkit.repositories.PaykitAllowanceLocalState
import to.bitkit.repositories.PaykitPaymentRequestId
import java.math.BigDecimal
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlin.time.Instant
import java.time.Instant as JavaInstant

/**
* One identity's allowance state in the wallet backup, in the wire format both apps share: dates are RFC 3339 strings
* with milliseconds and stages are lowercase. A restored wallet keeps its journal, so recovery can still settle open
* attempts, and its automatic-payment history.
*/
@Serializable
data class PaykitAllowanceStateBackup(
val groups: List<Group> = emptyList(),
val journal: List<JournalEntry> = emptyList(),
val autoPaidSatsByAllowance: Map<String, ULong> = emptyMap(),
val autoPaidRequestIds: Set<PaykitPaymentRequestId> = emptySet(),
val presentedProposalIds: Set<String> = emptySet(),
val unannouncedOfferIds: Set<String> = emptySet(),
val notifiedRequestIds: Set<String> = emptySet(),
val lastTrustedTime: String? = null,
) {
@Serializable
data class Group(
val id: String,
val counterparty: String,
val limits: Limits,
val allowanceIds: List<String>,
val createdAt: String,
)

/** US dollar limits travel as decimal strings, as iOS keeps them. */
@Serializable
data class Limits(
val perPaymentUsd: String,
val monthlyUsd: String,
val perPaymentSats: ULong,
val monthlySats: ULong,
)

@Serializable
data class JournalEntry(
val attemptId: String,
val isAutomatic: Boolean,
val requestId: PaykitPaymentRequestId,
val allowanceId: String? = null,
val amountSats: ULong,
val paymentEndpointIdentifier: String,
val paymentHash: String? = null,
val onchainAddress: String? = null,
val transactionId: String? = null,
val stage: String,
val createdAt: String,
val paymentListVersion: ULong? = null,
val previousPaymentListVersion: ULong? = null,
)

constructor(state: PaykitAllowanceLocalState) : this(
groups = state.groups.map {
val limits = Limits(
perPaymentUsd = it.limits.perPaymentUsd.toString(),
monthlyUsd = it.limits.monthlyUsd.toString(),
perPaymentSats = it.limits.perPaymentSats,
monthlySats = it.limits.monthlySats,
)
Group(it.id, it.counterparty, limits, it.allowanceIds, isoTime(it.createdAtMillis))
},
journal = state.journal.map {
JournalEntry(
attemptId = it.attemptId,
isAutomatic = it.isAutomatic,
requestId = it.requestId,
allowanceId = it.allowanceId,
amountSats = it.amountSats,
paymentEndpointIdentifier = it.paymentEndpointIdentifier,
paymentHash = it.paymentHash,
onchainAddress = it.onchainAddress,
transactionId = it.transactionId,
stage = it.stage.name.lowercase(Locale.ROOT),
createdAt = isoTime(it.createdAtMillis),
paymentListVersion = it.paymentListVersion,
previousPaymentListVersion = it.previousPaymentListVersion,
)
},
autoPaidSatsByAllowance = state.autoPaidSatsByAllowance,
autoPaidRequestIds = state.autoPaidRequestIds,
presentedProposalIds = state.presentedProposalIds,
unannouncedOfferIds = state.acceptingOfferIds,
notifiedRequestIds = state.notifiedRequestIds,
lastTrustedTime = state.lastTrustedTimeMillis?.let(::isoTime),
)

fun restored(): PaykitAllowanceLocalState {
val state = PaykitAllowanceLocalState(
groups = groups.map {
val limits = PaykitAllowanceLimits(
perPaymentUsd = BigDecimal(it.limits.perPaymentUsd).toInt(),
monthlyUsd = BigDecimal(it.limits.monthlyUsd).toInt(),
perPaymentSats = it.limits.perPaymentSats,
monthlySats = it.limits.monthlySats,
)
PaykitAllowanceLocalState.Group(it.id, it.counterparty, limits, it.allowanceIds, millis(it.createdAt))
},
journal = journal.map {
PaykitAllowanceLocalState.JournalEntry(
attemptId = it.attemptId,
isAutomatic = it.isAutomatic,
requestId = it.requestId,
allowanceId = it.allowanceId,
amountSats = it.amountSats,
paymentEndpointIdentifier = it.paymentEndpointIdentifier,
paymentHash = it.paymentHash,
onchainAddress = it.onchainAddress,
transactionId = it.transactionId,
// A stage this version does not know stays open, so recovery settles it from the node.
stage = stage(it.stage),
Comment thread
ovitrif marked this conversation as resolved.
createdAtMillis = millis(it.createdAt),
paymentListVersion = it.paymentListVersion,
previousPaymentListVersion = it.previousPaymentListVersion,
)
},
presentedProposalIds = presentedProposalIds,
acceptingOfferIds = unannouncedOfferIds,
notifiedRequestIds = notifiedRequestIds,
lastTrustedTimeMillis = lastTrustedTime?.let(::millis),
autoPaidSatsByAllowance = autoPaidSatsByAllowance,
autoPaidRequestIds = autoPaidRequestIds,
)
if (autoPaidSatsByAllowance.isNotEmpty() || autoPaidRequestIds.isNotEmpty()) return state
// A backup written by iOS keeps no lasting totals, so they are rebuilt from the restored journal.
val paid = state.journal.filter { it.isAutomatic && it.stage == PaykitAllowanceLocalState.Stage.SUCCEEDED }
return state.copy(
autoPaidSatsByAllowance = paid
.groupBy { PaykitAllowance.key(it.requestId.counterparty, it.allowanceId) }
.mapValues { (_, payments) ->
payments.fold(0uL) { total, payment -> total.safe() + payment.amountSats.safe() }
},
autoPaidRequestIds = paid.mapTo(mutableSetOf()) { it.requestId },
)
}
}

private fun stage(value: String): PaykitAllowanceLocalState.Stage =
PaykitAllowanceLocalState.Stage.entries.firstOrNull { it.name.equals(value, ignoreCase = true) }
?: PaykitAllowanceLocalState.Stage.UNKNOWN

private val isoTimeFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").withZone(ZoneOffset.UTC)

private fun isoTime(millis: Long): String = isoTimeFormat.format(JavaInstant.ofEpochMilli(millis))

private fun millis(isoTime: String): Long = Instant.parse(isoTime).toEpochMilliseconds()
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ data class PaykitPaymentStateBackup(
val activeOnchainAttempt: ActiveOnchainAttemptBackup? = null,
val usdt: PaykitUsdtStateBackup? = null,
val acceptedOneTimeRequests: Map<String, Set<PaykitPaymentRequestId>>? = null,
/** Allowance state by identity; absent when no identity has any. */
val allowances: Map<String, PaykitAllowanceStateBackup>? = null,
) {
@Serializable
data class Subscription(
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/to/bitkit/repositories/BackupRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import to.bitkit.models.BackupCategory
import to.bitkit.models.BackupItemStatus
import to.bitkit.models.BlocktankBackupV1
import to.bitkit.models.MetadataBackupV1
import to.bitkit.models.PaykitAllowanceStateBackup
import to.bitkit.models.PaykitPaymentStateBackup
import to.bitkit.models.SettingsBackupV1
import to.bitkit.models.Toast
Expand Down Expand Up @@ -128,6 +129,7 @@ class BackupRepo @Inject constructor(
private val paykitPaymentProofStore: PaykitPaymentProofStore,
private val paykitPaymentRequestRepo: Provider<PaykitPaymentRequestRepo>,
private val paykitPresentationStore: PaykitPaymentRequestPresentationStore,
private val paykitAllowanceStore: PaykitAllowanceStore,
private val keychain: Keychain,
private val onchainSendAttemptStore: OnchainSendAttemptStore,
private val lightningRepo: Provider<LightningRepo>,
Expand Down Expand Up @@ -412,6 +414,7 @@ class BackupRepo @Inject constructor(
)
)
dataListenerJobs.add(observeBackupChanges(paykitPresentationStore.backupStateVersion, BackupCategory.WALLET))
dataListenerJobs.add(observeBackupChanges(paykitAllowanceStore.backupStateVersion, BackupCategory.WALLET))
dataListenerJobs.add(
observeBackupChanges(
privatePaykitAddressReservationRepo.get().backupStateVersion,
Expand Down Expand Up @@ -825,6 +828,9 @@ class BackupRepo @Inject constructor(
activeOnchainAttempt = active,
usdt = paykitUsdtPaymentRepo.get().backupSnapshot(),
acceptedOneTimeRequests = paykitPresentationStore.acceptedOneTimeBackupSnapshot(),
allowances = paykitAllowanceStore.backupSnapshot()
.mapValues { PaykitAllowanceStateBackup(it.value) }
.ifEmpty { null },
Comment thread
ovitrif marked this conversation as resolved.
),
)

Expand Down Expand Up @@ -1039,6 +1045,7 @@ class BackupRepo @Inject constructor(
parsed.paykitPaymentState?.let {
// Validate every proof before writing a guard or replacing existing payment state.
it.pendingProofs.forEach { proof -> proof.restored() }
val allowances = it.allowances.orEmpty().mapValues { (_, state) -> state.restored() }
val walletIndex = backupWalletIndex()
it.activeOnchainAttempt?.let { wire ->
validateActiveProof(wire, it.pendingProofs)
Expand All @@ -1061,6 +1068,7 @@ class BackupRepo @Inject constructor(
paykitPresentationStore.restoreBackup(it.subscriptions)
paykitPaymentProofRepo.get().restoreBackup(it.pendingProofs)
paykitPresentationStore.restoreAcceptedOneTimeRequests(it.acceptedOneTimeRequests.orEmpty())
paykitAllowanceStore.restoreBackup(allowances)
Comment thread
ovitrif marked this conversation as resolved.
}
db.transferDao().upsert(parsed.transfers)
watchOnlyAccountRepo.restore(
Expand Down
Loading
Loading