diff --git a/app/src/main/java/to/bitkit/models/PaykitAllowanceStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitAllowanceStateBackup.kt new file mode 100644 index 0000000000..b4cb70feee --- /dev/null +++ b/app/src/main/java/to/bitkit/models/PaykitAllowanceStateBackup.kt @@ -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 = emptyList(), + val journal: List = emptyList(), + val autoPaidSatsByAllowance: Map = emptyMap(), + val autoPaidRequestIds: Set = emptySet(), + val presentedProposalIds: Set = emptySet(), + val unannouncedOfferIds: Set = emptySet(), + val notifiedRequestIds: Set = emptySet(), + val lastTrustedTime: String? = null, +) { + @Serializable + data class Group( + val id: String, + val counterparty: String, + val limits: Limits, + val allowanceIds: List, + 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), + 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() diff --git a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt index 24fc03dceb..38a9731d0f 100644 --- a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt +++ b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt @@ -19,6 +19,8 @@ data class PaykitPaymentStateBackup( val activeOnchainAttempt: ActiveOnchainAttemptBackup? = null, val usdt: PaykitUsdtStateBackup? = null, val acceptedOneTimeRequests: Map>? = null, + /** Allowance state by identity; absent when no identity has any. */ + val allowances: Map? = null, ) { @Serializable data class Subscription( diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 4e457d3ee3..d0c0d029e6 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -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 @@ -128,6 +129,7 @@ class BackupRepo @Inject constructor( private val paykitPaymentProofStore: PaykitPaymentProofStore, private val paykitPaymentRequestRepo: Provider, private val paykitPresentationStore: PaykitPaymentRequestPresentationStore, + private val paykitAllowanceStore: PaykitAllowanceStore, private val keychain: Keychain, private val onchainSendAttemptStore: OnchainSendAttemptStore, private val lightningRepo: Provider, @@ -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, @@ -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 }, ), ) @@ -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) @@ -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) } db.transferDao().upsert(parsed.transfers) watchOnlyAccountRepo.restore( diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index 0ae7112985..1752fe9c3d 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -10,6 +10,7 @@ import com.synonym.paykit.AllowanceSelectionInput import com.synonym.paykit.PaymentAttemptDecision import com.synonym.paykit.PaymentAttemptRecord import com.synonym.paykit.PaymentExecutionChecks +import com.synonym.paykit.PaymentExecutionMode import com.synonym.paykit.PaymentExecutionStatus import com.synonym.paykit.PaymentOccurrence import com.synonym.paykit.PaymentOutcome @@ -130,6 +131,10 @@ class PaykitAllowancePayer @Inject constructor( return PaymentOutcome.SUCCEEDED.takeIf { hasProof } } + /** True once the node can list its payments, so a payment missing from the list is one it never sent. */ + suspend fun canListLightningPayments(): Boolean = + runSuspendCatching { lightningRepo.listPaymentsOrNull() }.getOrNull() != null + /** 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() } @@ -280,6 +285,8 @@ class PaykitAllowanceExecutor @Inject constructor( // No ledger means nothing was ever admitted, so there is nothing to recover. paykitSdkService.allowanceAccountingState() ?: return@runSuspendCatching val state = ensureReconciled(identity) + // The ledger keeps every attempt, while the journal is trimmed and an iOS backup carries no totals. + updateLocalState(identity) { it.withLedgerAutoPaid(state.history) } // One read of the journal per pass: each attempt below is settled from this snapshot. val journal = localState(identity).journal.associateBy { it.attemptId } for (attempt in state.history.occurrences.flatMap { it.attempts }) { @@ -290,8 +297,12 @@ class PaykitAllowanceExecutor @Inject constructor( } PaymentExecutionStatus.SUBMITTED, PaymentExecutionStatus.UNKNOWN -> { val entry = journal[attempt.attemptId] - val outcome = entry?.let { journalOutcome(it) } - ?: PaymentOutcome.UNKNOWN.takeIf { attempt.status == PaymentExecutionStatus.SUBMITTED } + val outcome = if (entry != null && awaitsNode(entry)) { + null + } else { + entry?.let { journalOutcome(it) } + ?: PaymentOutcome.UNKNOWN.takeIf { attempt.status == PaymentExecutionStatus.SUBMITTED } + } outcome?.let { record(attempt.attemptId, it, identity) } // One that paid nothing gives the payee's list back, as a live failure does. if (outcome == PaymentOutcome.FAILED && entry?.isAutomatic == true) { @@ -369,8 +380,18 @@ class PaykitAllowanceExecutor @Inject constructor( } 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.FAILED -> PaymentOutcome.FAILED + // The live journal is written before the node call, but a restored snapshot may predate the send, + // so a node that knows the payment decides. + Stage.PREPARED, Stage.SUBMITTED -> if (awaitsNode(entry)) { + null + } else { + when (entry.paymentHash?.let { payer.lightningPaymentStatus(it) }) { + PaymentStatus.SUCCEEDED -> PaymentOutcome.SUCCEEDED + PaymentStatus.PENDING -> null + PaymentStatus.FAILED, null -> PaymentOutcome.FAILED + } + } Stage.SUCCEEDED -> PaymentOutcome.SUCCEEDED Stage.SENDING, Stage.SENT, Stage.UNKNOWN -> { val paymentHash = entry.paymentHash @@ -386,6 +407,12 @@ class PaykitAllowanceExecutor @Inject constructor( } } + // Before the node runs (a restore starts Pubky first) its empty list would read as never sent. + private suspend fun awaitsNode(entry: JournalEntry): Boolean = + (entry.stage == Stage.PREPARED || entry.stage == Stage.SUBMITTED) && + entry.paymentHash != null && + !payer.canListLightningPayments() + private suspend fun record(attemptId: String, outcome: PaymentOutcome, identity: String) { paykitSdkService.recordPaymentOutcome(PaymentOutcomeReport(attemptId, outcome)) val stage = when (outcome) { @@ -409,7 +436,11 @@ class PaykitAllowanceExecutor @Inject constructor( val updated = state.copy( journal = state.journal.map { if (it.attemptId == attemptId) it.copy(stage = stage) else it }, ) - if (current?.isPaidAutomaticallyBy(stage) == true) updated.withAutoPaid(current) else updated + if (current?.isPaidAutomaticallyBy(stage) == true) { + updated.withAutoPaid(current.requestId, current.allowanceId, current.amountSats) + } else { + updated + } } val entry = before ?: return null if (entry.isPaidAutomaticallyBy(stage)) { @@ -707,10 +738,12 @@ class PaykitAllowanceExecutor @Inject constructor( * 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. + * A Lightning payment passes its [paymentHash], so a backup taken before the send still lets the node settle it. */ suspend fun beginManualPayment( request: PaykitPaymentRequest, paymentEndpointIdentifier: String, + paymentHash: String? = null, ): Result = withContext(ioDispatcher) { val identity = activeIdentity val amountSats = request.allowanceSats() @@ -727,7 +760,8 @@ class PaykitAllowanceExecutor @Inject constructor( request, amountSats, paymentEndpointIdentifier, - identity + paymentHash, + identity, ) } val error = result.exceptionOrNull() ?: return@withContext result @@ -740,6 +774,7 @@ class PaykitAllowanceExecutor @Inject constructor( request: PaykitPaymentRequest, amountSats: ULong, paymentEndpointIdentifier: String, + paymentHash: String?, identity: String, ): String? { ensureReconciled(identity) @@ -759,7 +794,7 @@ class PaykitAllowanceExecutor @Inject constructor( // Recovery leaves the attempt alone until finishManualPayment reports its outcome. liveAttemptIds.add(prepared.attemptId) val attemptId = runSuspendCatching { - beginManualAttempt(request, amountSats, paymentEndpointIdentifier, prepared, identity) + beginManualAttempt(request, amountSats, paymentEndpointIdentifier, paymentHash, prepared, identity) } if (attemptId.getOrNull() == null) liveAttemptIds.remove(prepared.attemptId) // Nothing was sent yet, so a failed journal write releases the attempt instead of blocking a retry. @@ -774,6 +809,7 @@ class PaykitAllowanceExecutor @Inject constructor( request: PaykitPaymentRequest, amountSats: ULong, paymentEndpointIdentifier: String, + paymentHash: String?, prepared: PaymentAttemptRecord, identity: String, ): String? { @@ -785,6 +821,7 @@ class PaykitAllowanceExecutor @Inject constructor( allowanceId = null, amountSats = amountSats, paymentEndpointIdentifier = paymentEndpointIdentifier, + paymentHash = paymentHash?.lowercase(), stage = Stage.PREPARED, createdAtMillis = clock.now().toEpochMilliseconds(), ), @@ -946,16 +983,44 @@ private fun PaykitPaymentRequest.scope() = PaymentRequestScope(counterparty, pay private fun JournalEntry.isPaidAutomaticallyBy(stage: Stage): Boolean = isAutomatic && this.stage != Stage.SUCCEEDED && stage == Stage.SUCCEEDED -private fun PaykitAllowanceLocalState.withAutoPaid(entry: JournalEntry): PaykitAllowanceLocalState { +private fun PaykitAllowanceLocalState.withAutoPaid( + requestId: PaykitPaymentRequestId, + allowanceId: String?, + amountSats: ULong, +): PaykitAllowanceLocalState { + // Both the journal and the ledger report a payment; whichever comes first counts it. + if (isAutoPaid(requestId)) return this // Allowance ids are unique per contact only, so payments count toward the contact's own allowance. - val key = PaykitAllowance.key(entry.requestId.counterparty, entry.allowanceId) - val total = (autoPaidSatsByAllowance[key] ?: 0uL).safe() + entry.amountSats.safe() + val key = PaykitAllowance.key(requestId.counterparty, allowanceId) + val total = (autoPaidSatsByAllowance[key] ?: 0uL).safe() + amountSats.safe() return copy( autoPaidSatsByAllowance = autoPaidSatsByAllowance + (key to total), - autoPaidRequestIds = autoPaidRequestIds + entry.requestId, + autoPaidRequestIds = autoPaidRequestIds + requestId, ) } +private fun PaykitAllowanceLocalState.isAutoPaid(requestId: PaykitPaymentRequestId) = autoPaidRequestIds.any { + it.paymentRequestId == requestId.paymentRequestId && + PubkyPublicKeyFormat.matches(it.counterparty, requestId.counterparty) +} + +private fun PaykitAllowanceLocalState.withLedgerAutoPaid( + history: AllowanceAccountingHistory, +): PaykitAllowanceLocalState = + history.occurrences.filter { it.key.billingPeriod == null }.fold(this) { state, occurrence -> + val scope = occurrence.key.request + val requestId = PaykitPaymentRequestId(scope.paymentRequestId, scope.counterparty) + val paid = occurrence.attempts.firstOrNull { + it.mode == PaymentExecutionMode.AUTOMATIC && it.status == PaymentExecutionStatus.SUCCEEDED + } + val amountSats = paid?.amount?.value()?.let(PaykitAllowance::satsFromBitcoinAmount) + if (paid == null || amountSats == null) { + state + } else { + state.withAutoPaid(requestId, paid.allowanceId, amountSats) + } + } + /** * Allowances pay only bitcoin-denominated requests; null for any other request. A conversion offered for other rails, * such as USDT, does not change the bitcoin amount a Lightning payment sends. When it prices a bitcoin rail too, the diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt index 4d37012615..3ff41b3c0e 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -165,7 +165,11 @@ class PaykitAllowanceRepo @Inject constructor( .map { it.nodeLifecycleState.isRunning() } .distinctUntilChanged() .filter { it } - .collect { executor.settleOpenLightningAttempts() } + .collect { + // Attempts recovery left open while the node could not list payments are settled now. + activeIdentity?.let { identity -> executor.recover(identity) } + executor.settleOpenLightningAttempts() + } } } @@ -583,8 +587,11 @@ class PaykitAllowanceRepo @Inject constructor( * 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 beginManualPayment( + request: PaykitPaymentRequest, + paymentEndpointIdentifier: String, + paymentHash: String?, + ): Result = executor.beginManualPayment(request, paymentEndpointIdentifier, paymentHash) suspend fun manualLightningPaymentSent(attemptId: String, paymentHash: String) = executor.manualLightningPaymentSent(attemptId, paymentHash) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt index 92a9f62c87..e33b50956c 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt @@ -1,5 +1,9 @@ package to.bitkit.repositories +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -80,6 +84,11 @@ class PaykitAllowanceStore @Inject constructor( val statesByIdentity: Map = emptyMap(), ) + private val _backupStateVersion = MutableStateFlow(0L) + + /** Moves when backed-up state changes; a new trusted-time watermark alone does not ask for a backup. */ + val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() + fun load(identity: String): PaykitAllowanceLocalState = runCatching { loadAll().statesByIdentity[storageKey(identity)] } .onFailure { Logger.warn("Failed to load Paykit allowance state", it, context = TAG) } @@ -88,10 +97,32 @@ class PaykitAllowanceStore @Inject constructor( suspend fun save(identity: String, state: PaykitAllowanceLocalState) { val stored = loadAll() + val previous = stored.statesByIdentity[storageKey(identity)] val updated = stored.copy(statesByIdentity = stored.statesByIdentity + (storageKey(identity) to state)) keychain.upsertString(KEY, storeJson.encodeToString(updated)) + if (previous?.withoutWatermark() != state.withoutWatermark()) _backupStateVersion.update { it + 1 } + } + + /** + * Every identity's allowance state, for the wallet backup. Throws when the stored state cannot be read, so a backup + * never replaces a recoverable one with nothing. + */ + fun backupSnapshot(): Map { + val value = keychain.loadString(KEY) ?: return emptyMap() + return storeJson.decodeFromString(value).statesByIdentity } + /** Replaces the allowance state of each identity in [states], before recovery reads it. */ + suspend fun restoreBackup(states: Map) { + if (states.isEmpty()) return + val stored = loadAll() + val restored = states.mapKeys { (identity, _) -> storageKey(identity) } + keychain.upsertString(KEY, storeJson.encodeToString(stored.copy(stored.statesByIdentity + restored))) + _backupStateVersion.update { it + 1 } + } + + private fun PaykitAllowanceLocalState.withoutWatermark() = copy(lastTrustedTimeMillis = null) + private fun loadAll(): Stored { val value = keychain.loadString(KEY) ?: return Stored() return runCatching { storeJson.decodeFromString(value) } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt index 6f24036e58..4a06e3cb18 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt @@ -80,6 +80,7 @@ internal data class StoredPaymentEntry( internal data class PrivatePaykitBackup( val sdkState: String, val consumedPrivatePaymentListVersions: Map, + val consumedPrivatePaymentListOwners: Map = emptyMap(), ) internal data class StoredInvoice( diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 40a4e244d0..1444b5f742 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -868,6 +868,10 @@ class PrivatePaykitRepo @Inject constructor( .mapNotNull { (publicKey, contactState) -> contactState.consumedPrivatePaymentListVersion?.let { publicKey to it } }.toMap(), + consumedPrivatePaymentListOwners = ensureState().contacts + .mapNotNull { (publicKey, contactState) -> + contactState.consumedPrivatePaymentListOwner?.let { publicKey to it } + }.toMap(), ) ) PrivatePaykitBackupSnapshot(state, exported.version) @@ -887,9 +891,13 @@ class PrivatePaykitRepo @Inject constructor( if (backup == null) { paykitSdkService.clearState() } else { - requireNotNull(decoded).consumedPrivatePaymentListVersions.forEach { (publicKey, versions) -> - ensureState().contacts.getOrPut(publicKey) { ContactState() } - .consumedPrivatePaymentListVersion = versions + val restored = requireNotNull(decoded) + restored.consumedPrivatePaymentListVersions.forEach { (publicKey, versions) -> + ensureState().contacts.getOrPut(publicKey) { ContactState() }.apply { + consumedPrivatePaymentListVersion = versions + // The owner lets a restored allowance attempt still give back the list it consumed. + consumedPrivatePaymentListOwner = restored.consumedPrivatePaymentListOwners[publicKey] + } } } persistState(preserveCleanupMarkers = false) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index e39959f5e3..cd2d98627d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -6065,7 +6065,10 @@ class AppViewModel @Inject constructor( ): Result { if (request == null) return Result.success(null) val endpointIdentifier = paymentProofPreparation(contactPaymentContext).endpointIdentifier - return paykitAllowanceRepo.beginManualPayment(request, endpointIdentifier) + val paymentHash = _sendUiState.value.decodedInvoice + ?.takeIf { _sendUiState.value.payMethod == SendMethod.LIGHTNING } + ?.paymentHash?.toHex() + return paykitAllowanceRepo.beginManualPayment(request, endpointIdentifier, paymentHash) .recoverCatching { if (it is PaykitAllowanceError.PaymentAlreadyRecorded) throw it Logger.warn("Failed to report a manual payment to the allowance ledger", it, context = TAG) diff --git a/app/src/test/java/to/bitkit/models/PaykitAllowanceStateBackupTest.kt b/app/src/test/java/to/bitkit/models/PaykitAllowanceStateBackupTest.kt new file mode 100644 index 0000000000..6888d9781b --- /dev/null +++ b/app/src/test/java/to/bitkit/models/PaykitAllowanceStateBackupTest.kt @@ -0,0 +1,135 @@ +package to.bitkit.models + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import org.junit.Test +import to.bitkit.repositories.PaykitAllowanceLimits +import to.bitkit.repositories.PaykitAllowanceLocalState +import to.bitkit.repositories.PaykitAllowanceLocalState.Stage +import to.bitkit.repositories.PaykitPaymentRequestId +import java.util.Locale +import kotlin.test.assertEquals + +class PaykitAllowanceStateBackupTest { + companion object { + private val REQUEST_ID = PaykitPaymentRequestId("request-1", "pubky" + "y".repeat(52)) + private const val CREATED_AT_MILLIS = 1_793_062_800_000L + + /** The wire format both apps read and write; field names and encodings must not drift. */ + private val GOLDEN = """ + {"groups":[{"id":"group-1","counterparty":"pubky${"y".repeat(52)}", + "limits":{"perPaymentUsd":"5","monthlyUsd":"50","perPaymentSats":5000,"monthlySats":50000}, + "allowanceIds":["allowance-1"],"createdAt":"2026-10-27T01:00:00.000Z"}], + "journal":[{"attemptId":"attempt-1","isAutomatic":true, + "requestId":{"paymentRequestId":"request-1","counterparty":"pubky${"y".repeat(52)}", + "billingPeriodStartsAt":null},"allowanceId":"allowance-1","amountSats":1000, + "paymentEndpointIdentifier":"btc-lightning-bolt11","paymentHash":"${"ab".repeat(32)}", + "onchainAddress":null,"transactionId":null,"stage":"succeeded","createdAt":"2026-10-27T01:00:00.000Z", + "paymentListVersion":4,"previousPaymentListVersion":3}], + "autoPaidSatsByAllowance":{"pubky${"y".repeat(52)}/allowance-1":1000}, + "autoPaidRequestIds":[{"paymentRequestId":"request-1","counterparty":"pubky${"y".repeat(52)}", + "billingPeriodStartsAt":null}], + "presentedProposalIds":["proposal-1"],"unannouncedOfferIds":["pubky${"y".repeat(52)}/allowance-2"], + "notifiedRequestIds":["request-2"], + "lastTrustedTime":"2026-10-27T01:00:00.000Z"} + """.trimIndent().replace("\n", "") + } + + private val json = Json { encodeDefaults = true } + + @Test + fun `allowance state survives the backup in the shared wire format`() { + val state = localState() + + val wire = json.encodeToString(PaykitAllowanceStateBackup(state)) + + assertEquals(json.parseToJsonElement(GOLDEN).jsonObject, json.parseToJsonElement(wire) as JsonObject) + assertEquals(state, json.decodeFromString(GOLDEN).restored()) + } + + @Test + fun `a backup without lasting totals rebuilds them from its journal`() { + val fromIos = json.decodeFromString(GOLDEN) + .copy(autoPaidSatsByAllowance = emptyMap(), autoPaidRequestIds = emptySet()) + + val restored = fromIos.restored() + + assertEquals(mapOf("${REQUEST_ID.counterparty}/allowance-1" to 1_000uL), restored.autoPaidSatsByAllowance) + assertEquals(setOf(REQUEST_ID), restored.autoPaidRequestIds) + } + + @Test + fun `a journal entry that omits its optional fields still restores`() { + val wire = """ + {"journal":[{"attemptId":"manual-1","isAutomatic":false, + "requestId":{"paymentRequestId":"request-1","counterparty":"${REQUEST_ID.counterparty}"}, + "amountSats":1000,"paymentEndpointIdentifier":"btc-regtest-p2wpkh","stage":"sent", + "createdAt":"2026-10-27T01:00:00.000Z"}]} + """.trimIndent().replace("\n", "") + + val entry = json.decodeFromString(wire).restored().journal.single() + + assertEquals(null, entry.allowanceId) + assertEquals(null, entry.paymentListVersion) + assertEquals(Stage.SENT, entry.stage) + } + + @Test + fun `a journal stage this version does not know restores as unknown`() { + val wire = GOLDEN.replace("\"stage\":\"succeeded\"", "\"stage\":\"settling\"") + + val restored = json.decodeFromString(wire).restored() + + assertEquals(Stage.UNKNOWN, restored.journal.single().stage) + } + + @Test + fun `journal stages are written in ASCII under any device locale`() { + val default = Locale.getDefault() + Locale.setDefault(Locale.forLanguageTag("tr-TR")) + try { + val state = localState().let { it.copy(journal = it.journal.map { e -> e.copy(stage = Stage.SUBMITTED) }) } + + val backup = PaykitAllowanceStateBackup(state) + + assertEquals("submitted", backup.journal.single().stage) + } finally { + Locale.setDefault(default) + } + } + + private fun localState() = PaykitAllowanceLocalState( + groups = listOf( + PaykitAllowanceLocalState.Group( + id = "group-1", + counterparty = REQUEST_ID.counterparty, + limits = PaykitAllowanceLimits(5, 50, 5_000uL, 50_000uL), + allowanceIds = listOf("allowance-1"), + createdAtMillis = CREATED_AT_MILLIS, + ), + ), + journal = listOf( + PaykitAllowanceLocalState.JournalEntry( + attemptId = "attempt-1", + isAutomatic = true, + requestId = REQUEST_ID, + allowanceId = "allowance-1", + amountSats = 1_000uL, + paymentEndpointIdentifier = "btc-lightning-bolt11", + paymentHash = "ab".repeat(32), + stage = Stage.SUCCEEDED, + createdAtMillis = CREATED_AT_MILLIS, + paymentListVersion = 4uL, + previousPaymentListVersion = 3uL, + ), + ), + presentedProposalIds = setOf("proposal-1"), + acceptingOfferIds = setOf("${REQUEST_ID.counterparty}/allowance-2"), + notifiedRequestIds = setOf("request-2"), + lastTrustedTimeMillis = CREATED_AT_MILLIS, + autoPaidSatsByAllowance = mapOf("${REQUEST_ID.counterparty}/allowance-1" to 1_000uL), + autoPaidRequestIds = setOf(REQUEST_ID), + ) +} diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index a568469e91..9b893e0e9d 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -62,6 +62,7 @@ import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus import to.bitkit.models.KnownDevice import to.bitkit.models.MetadataBackupV1 +import to.bitkit.models.PaykitAllowanceStateBackup import to.bitkit.models.PaykitPaymentStateBackup import to.bitkit.models.PaykitUsdtStateBackup import to.bitkit.models.TransportType @@ -111,6 +112,7 @@ class BackupRepoTest : BaseUnitTest() { private val paykitPaymentProofStore = mock() private val paykitPaymentRequestRepo = mock() private val paykitPresentationStore = mock() + private val paykitAllowanceStore = mock() private val keychain = mock() private val onchainSendAttemptStore = mock() private val vssStoreIdProvider = mock() @@ -181,6 +183,7 @@ class BackupRepoTest : BaseUnitTest() { whenever(usdtRepo.state).thenReturn(MutableStateFlow(UsdtWalletState())) whenever(paykitUsdtPaymentRepo.backupStateVersion).thenReturn(MutableStateFlow(0L)) whenever(paykitPresentationStore.backupSnapshot()).thenReturn(emptyMap()) + whenever(paykitAllowanceStore.backupSnapshot()).thenReturn(emptyMap()) whenever { privatePaykitAddressReservationRepo.restoreBackup(any()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitAddressReservationRepo.backupSnapshot() }.thenReturn(Result.success(null)) whenever(privatePaykitAddressReservationRepo.backupStateVersion).thenReturn(MutableStateFlow(0L)) @@ -623,6 +626,38 @@ class BackupRepoTest : BaseUnitTest() { } } + @Test + fun `restore brings back each identity's allowance journal and history`() = test { + val identity = "pubky" + "z".repeat(52) + val allowances = PaykitAllowanceStateBackup( + journal = listOf( + PaykitAllowanceStateBackup.JournalEntry( + attemptId = "attempt-1", + isAutomatic = true, + requestId = PaykitPaymentRequestId("request-1", "pubky" + "y".repeat(52)), + allowanceId = "allowance-1", + amountSats = 1_000uL, + paymentEndpointIdentifier = "btc-lightning-bolt11", + paymentHash = "ab".repeat(32), + stage = "sent", + createdAt = "2026-10-27T01:00:00Z", + ), + ), + autoPaidSatsByAllowance = mapOf("pubky${"y".repeat(52)}/allowance-1" to 3_000uL), + ) + stubWalletBackup( + paykitPaymentState = PaykitPaymentStateBackup( + subscriptions = emptyMap(), + pendingProofs = emptyList(), + allowances = mapOf(identity to allowances), + ), + ) + + sut.performFullRestoreFromLatestBackup().getOrThrow() + + verify(paykitAllowanceStore).restoreBackup(mapOf(identity to allowances.restored())) + } + @Test fun `restored accepted Shop reconciles after proof and private state`() = test { val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() @@ -2115,6 +2150,7 @@ class BackupRepoTest : BaseUnitTest() { whenever(privatePaykitAddressReservationRepo.backupStateVersion).thenReturn(MutableStateFlow(0L)) whenever(paykitPaymentProofStore.backupStateVersion).thenReturn(MutableStateFlow(0L)) whenever(paykitPresentationStore.backupStateVersion).thenReturn(MutableStateFlow(0L)) + whenever(paykitAllowanceStore.backupStateVersion).thenReturn(MutableStateFlow(0L)) whenever(preActivityMetadataRepo.preActivityMetadataChanged).thenReturn(MutableStateFlow(0L)) whenever(lightningService.syncStatusChanged).thenReturn(MutableSharedFlow()) } @@ -2159,6 +2195,7 @@ class BackupRepoTest : BaseUnitTest() { paykitPaymentProofStore = paykitPaymentProofStore, paykitPaymentRequestRepo = Provider { paykitPaymentRequestRepo }, paykitPresentationStore = paykitPresentationStore, + paykitAllowanceStore = paykitAllowanceStore, keychain = keychain, onchainSendAttemptStore = onchainSendAttemptStore, lightningRepo = Provider { lightningRepo }, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt index ce58b43580..f81083ab23 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -46,6 +46,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import to.bitkit.data.keychain.Keychain +import to.bitkit.models.PaykitAllowanceStateBackup import to.bitkit.models.PaykitAmount import to.bitkit.models.PaykitAsset import to.bitkit.models.PaykitRequestPricing @@ -101,6 +102,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { private val recordedOutcomes = mutableListOf() private val reconciliations = mutableListOf() private val nodeStatuses = mutableMapOf() + private var nodeListsPayments = true private val events = mutableListOf() private lateinit var sut: PaykitAllowanceExecutor @@ -227,6 +229,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { whenever(payer.payLightning(any(), anyOrNull(), anyOrNull(), any())).thenReturn(Result.success(PAYMENT_HASH)) whenever(payer.failLightningPayment(any(), any())).thenReturn(true) whenever(payer.lightningPaymentStatus(any())).thenAnswer { nodeStatuses[it.getArgument(0)] } + whenever(payer.canListLightningPayments()).thenAnswer { nodeListsPayments } } // region Admission @@ -897,6 +900,95 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { ) } + @Test + fun `recovery counts automatic payments the ledger keeps beyond the journal`() = test { + val counted = fixtures.paymentRequest(id = "req-counted") + sut.updateLocalState(identity) { + it.copy( + autoPaidSatsByAllowance = mapOf(PaykitAllowance.key(counted.counterparty, ALLOWANCE_ID) to 1_000uL), + autoPaidRequestIds = setOf(counted.id), + ) + } + accountingState = fixtures.accountingState( + occurrences = listOf( + occurrence("counted", PaymentExecutionStatus.SUCCEEDED), + occurrence("restored", PaymentExecutionStatus.SUCCEEDED), + occurrence("failed", PaymentExecutionStatus.FAILED), + ), + ) + + sut.recover(identity) + + val state = sut.localState(identity) + val key = PaykitAllowance.key(counted.counterparty, ALLOWANCE_ID) + assertEquals(mapOf(key to 2_000uL), state.autoPaidSatsByAllowance, "Only the uncounted success is added") + assertEquals( + setOf("req-counted", "req-restored"), + state.autoPaidRequestIds.mapTo(mutableSetOf()) { it.paymentRequestId }, + ) + } + + @Test + fun `a ledger success whose journal entry is still open counts once`() = test { + sut.activate(identity) + val request = fixtures.paymentRequest(id = "req-pending") + seedJournal(journalEntry("pending", request, Stage.SENT, paymentHash = PAYMENT_HASH)) + accountingState = fixtures.accountingState( + occurrences = listOf(occurrence("pending", PaymentExecutionStatus.SUCCEEDED)), + ) + + sut.recover(identity) + + val state = sut.localState(identity) + assertEquals(Stage.SUCCEEDED, state.journal.single().stage) + assertEquals( + mapOf(PaykitAllowance.key(request.counterparty, ALLOWANCE_ID) to 1_000uL), + state.autoPaidSatsByAllowance, + ) + } + + @Test + fun `recovery settles a restored submitted attempt from the node`() = test { + val request = fixtures.paymentRequest() + accountingState = fixtures.accountingState( + occurrences = listOf(occurrence("restored", PaymentExecutionStatus.SUBMITTED)), + ) + val captured = PaykitAllowanceLocalState( + journal = listOf(journalEntry("restored", request, Stage.SUBMITTED, paymentHash = PAYMENT_HASH)), + ) + seedJournal(*PaykitAllowanceStateBackup(captured).restored().journal.toTypedArray()) + nodeStatuses[PAYMENT_HASH] = PaymentStatus.SUCCEEDED + + sut.recover(identity) + + assertEquals(listOf(PaymentOutcome.SUCCEEDED), recordedOutcomes.map { it.outcome }) + assertEquals(Stage.SUCCEEDED, sut.localState(identity).journal.single().stage) + } + + @Test + fun `a restored submitted attempt waits until the node can list payments`() = test { + val request = fixtures.paymentRequest() + accountingState = fixtures.accountingState( + occurrences = listOf(occurrence("restored", PaymentExecutionStatus.SUBMITTED)), + ) + val captured = PaykitAllowanceLocalState( + journal = listOf(journalEntry("restored", request, Stage.SUBMITTED, paymentHash = PAYMENT_HASH)), + ) + seedJournal(*PaykitAllowanceStateBackup(captured).restored().journal.toTypedArray()) + nodeStatuses[PAYMENT_HASH] = PaymentStatus.SUCCEEDED + nodeListsPayments = false + + sut.recover(identity) + + assertTrue(recordedOutcomes.isEmpty(), "Nothing is settled before the node runs") + assertEquals(Stage.SUBMITTED, sut.localState(identity).journal.single().stage) + + nodeListsPayments = true + sut.recover(identity) + + assertEquals(listOf(PaymentOutcome.SUCCEEDED), recordedOutcomes.map { it.outcome }) + } + @Test fun `recovery gives the payment list back when it fails an automatic attempt`() = test { val request = fixtures.paymentRequest() @@ -1104,6 +1196,34 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { ) } + @Test + fun `recovery settles a manual lightning payment restored from a backup taken before the send`() = test { + sut.activate(identity) + val attemptId = checkNotNull( + sut.beginManualPayment(fixtures.paymentRequest(), fixtures.lightningIdentifier, PAYMENT_HASH.uppercase()) + .getOrThrow(), + ) + val captured = sut.localState(identity) + assertEquals(Stage.SUBMITTED, captured.journal.single().stage) + seedJournal(*PaykitAllowanceStateBackup(captured).restored().journal.toTypedArray()) + accountingState = fixtures.accountingState( + occurrences = listOf(occurrence(attemptId, PaymentExecutionStatus.SUBMITTED)), + ) + nodeStatuses[PAYMENT_HASH] = PaymentStatus.SUCCEEDED + val restarted = PaykitAllowanceExecutor( + ioDispatcher = testDispatcher, + paykitSdkService = sdk, + store = PaykitAllowanceStore(keychain), + payer = payer, + clock = clock, + accountingAmount = { _, _ -> fixtures.accountingAmount("0.00001") }, + ) + + restarted.recover(identity) + + assertEquals(listOf(PaymentOutcomeReport(attemptId, PaymentOutcome.SUCCEEDED)), recordedOutcomes) + } + @Test fun `a released manual on-chain payment is recorded failed`() = test { sut.activate(identity) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt index 994d3e26bf..033ef32751 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt @@ -20,6 +20,7 @@ import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentFailureReason import org.mockito.kotlin.any +import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq @@ -871,6 +872,16 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { assertEquals(mapOf(entryKey to 1_000uL), sut.autoPaidSats.value) } + @Test + fun `running node recovers attempts left open for it`() = test { + sut.activate(identity) + clearInvocations(executor) + + nodeReady() + + verify(executor).recover(identity) + } + @Test fun `running node settles open lightning attempts`() = test { lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceStoreTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceStoreTest.kt new file mode 100644 index 0000000000..9d1a416b57 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceStoreTest.kt @@ -0,0 +1,63 @@ +package to.bitkit.repositories + +import kotlinx.serialization.SerializationException +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +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 kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class PaykitAllowanceStoreTest : BaseUnitTest() { + private val identity = PaykitAllowanceFixtures.identityKey + private val keychain = mock() + private var stored: String? = null + private lateinit var sut: PaykitAllowanceStore + + @Before + fun setUp() = test { + whenever(keychain.loadString(any())).thenAnswer { stored } + whenever(keychain.upsertString(any(), any())).doSuspendableAnswer { stored = it.getArgument(1) } + sut = PaykitAllowanceStore(keychain) + } + + @Test + fun `only a change to backed-up state asks for a backup`() = test { + sut.save(identity, PaykitAllowanceLocalState(lastTrustedTimeMillis = 1L)) + val afterFirstSave = sut.backupStateVersion.value + + sut.save(identity, PaykitAllowanceLocalState(lastTrustedTimeMillis = 2L)) + assertEquals(afterFirstSave, sut.backupStateVersion.value, "A new trusted-time watermark alone asks for none") + + sut.save(identity, PaykitAllowanceLocalState(notifiedRequestIds = setOf("request-1"))) + assertEquals(afterFirstSave + 1, sut.backupStateVersion.value) + } + + @Test + fun `a backup snapshot of unreadable state fails instead of backing up nothing`() = test { + stored = "{not json" + + assertFailsWith { sut.backupSnapshot() } + assertEquals(PaykitAllowanceLocalState(), sut.load(identity), "Ordinary reads still fall back to empty") + } + + @Test + fun `restore replaces each restored identity's state`() = test { + val other = PaykitAllowanceFixtures.counterpartyKey + sut.save(identity, PaykitAllowanceLocalState(notifiedRequestIds = setOf("old"))) + sut.save(other, PaykitAllowanceLocalState(notifiedRequestIds = setOf("kept"))) + val restored = PaykitAllowanceLocalState(autoPaidSatsByAllowance = mapOf("key" to 1_000uL)) + val before = sut.backupStateVersion.value + + sut.restoreBackup(mapOf(identity to restored)) + + assertEquals(restored, sut.load(identity)) + assertEquals(setOf("kept"), sut.load(other).notifiedRequestIds) + assertEquals(before + 1, sut.backupStateVersion.value) + assertEquals(2, sut.backupSnapshot().size) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 0d4a39ac5c..0372a95ddb 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -4235,6 +4235,22 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(paykitSdkService) { retainRecoveryBackup(backup) } } + @Test + fun `a restored payment list can still be given back by the attempt that consumed it`() = test { + val context = PrivatePaykitPaymentContext(emptyMap(), 7uL) + whenever(paykitSdkService.backupStateVersion).thenReturn(MutableStateFlow(0L)) + whenever(paykitSdkService.exportBackupState(Priority.Interactive)).thenReturn(PaykitSdkBackupExport("sdk", 0)) + sut.consumePrivatePaymentList(CONTACT_KEY, context, owner = "attempt-a").getOrThrow() + + val snapshot = sut.backupSnapshot().getOrThrow().state + sut.restoreBackup(snapshot).getOrThrow() + sut.releasePrivatePaymentList(CONTACT_KEY, context, owner = "attempt-b").getOrThrow() + assertEquals(7uL, cacheData.value.contacts[CONTACT_KEY]?.consumedPrivatePaymentListVersion) + + sut.releasePrivatePaymentList(CONTACT_KEY, context, owner = "attempt-a").getOrThrow() + assertNull(cacheData.value.contacts[CONTACT_KEY]?.consumedPrivatePaymentListVersion) + } + private fun stubContactPreparationPriorities() { for (priority in listOf(Priority.Background, Priority.Interactive)) { whenever { paykitSdkService.ensureLinkWithPeer(any(), any(), eq(priority)) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index a39c3b3de4..ac9decd7ec 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -801,7 +801,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitAllowanceRepo.isUpdatingFlow).thenReturn(allowanceUpdating) whenever(paykitAllowanceRepo.offersToAnnounce).thenReturn(MutableStateFlow(emptyList())) whenever { paykitAllowanceRepo.refresh() }.thenReturn(Result.success(Unit)) - whenever { paykitAllowanceRepo.beginManualPayment(any(), any()) }.thenReturn(Result.success(null)) + whenever { paykitAllowanceRepo.beginManualPayment(any(), any(), anyOrNull()) }.thenReturn(Result.success(null)) whenever { paykitAllowanceRepo.acceptOffersFromAllowers(any(), any()) }.thenReturn(emptyList()) whenever { paykitAllowanceRepo.processIncomingRequests(any()) }.thenReturn(false) whenever { paykitAllowanceRepo.isAutomaticallyHandling(any()) }.thenReturn(false) @@ -13610,7 +13610,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val privateContext = PrivatePaykitPaymentContext(mapOf(MethodId.P2wpkh.rawValue to "bitkit"), 7uL + index.toULong()) val attemptId = "manual-$index" - whenever(paykitAllowanceRepo.beginManualPayment(eq(request), any())).thenReturn(Result.success(attemptId)) + whenever(paykitAllowanceRepo.beginManualPayment(eq(request), any(), anyOrNull())) + .thenReturn(Result.success(attemptId)) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -13765,7 +13766,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(incoming)).thenReturn(Result.success(Unit)) - whenever(paykitAllowanceRepo.beginManualPayment(eq(incoming), any())).thenReturn(Result.success("attempt-b")) + whenever(paykitAllowanceRepo.beginManualPayment(eq(incoming), any(), anyOrNull())) + .thenReturn(Result.success("attempt-b")) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) setActiveContactPaymentContext(testPublicKey, privateContext, incoming, isInitialSubscriptionPayment = true) diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md index 0d1a8bd571..aebf6772da 100644 --- a/journeys/allowances/README.md +++ b/journeys/allowances/README.md @@ -4,6 +4,7 @@ Cover the Paykit allowance lifecycle between two Bitkit instances: the payer set contact, the payee's wallet accepts it by itself, 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. +The restore journey checks that a payer restored from its recovery phrase keeps its allowance history. ## Setup diff --git a/journeys/allowances/restore-keeps-history.xml b/journeys/allowances/restore-keeps-history.xml new file mode 100644 index 0000000000..a48aaf926e --- /dev/null +++ b/journeys/allowances/restore-keeps-history.xml @@ -0,0 +1,25 @@ + + + A payer restored from its recovery phrase keeps its allowance history: the allowances list, the + "Paid automatically" total and the "Auto-paid" tag come back from the wallet backup. Reset And + Restore wipes the keychain, where the allowance state lives, so a restore on the same instance + reads it back from the backup; no second install is needed. Requires the Active allowance from + set-and-accept.xml and the payer's notification permission granted. The journey wipes the payer + half way through, so do not run it against a wallet whose funds matter. + + + 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 payer instance, open the menu, navigate to Settings, then the "Security" tab (testTag "Tab-security"), then tap "Back Up Your Money" (testTag "BackupWallet"), continue past the intro (testTag "BackupIntroViewContinue"), tap to reveal the phrase (testTag "TapToReveal") and record the phrase from the word grid (testTag "SeedContainer") + On the payee instance, open the drawer menu, tap Subscriptions, tap the Payments tab (testTag "Tab-payments") and send the payer a one-time Payment Request for $2 with the note "Restore" + Switch to the payer instance, with Bitkit in the foreground, and 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` + On the payer instance, open the drawer menu, tap Subscriptions and tap the Allowances tab (testTag "Tab-allowances"), then tap the payee's row (testTag "AllowanceRow-<entryId>") and record PAID AUTOMATICALLY (testTag "AllowancePaidSoFar") + Open Settings, then the "Security" tab, then "Data Backups" (testTag "BackupSettings"), and wait on the list (testTag "BackupScrollView") until the "Boosts & Transfers" row reports a backup made after the payment — that row carries no testTag, so read its text + Open Settings, then the "Security" tab, then "Reset And Restore" (testTag "ResetAndRestore"), tap "Reset Wallet" (testTag "restore_reset_button") and confirm in the dialog (testTag "restore_reset_dialog") + On the onboarding screen tap "Restore" (testTag "RestoreWallet"), enter the recorded words into the word inputs (testTag "Word-0" onwards) and tap Restore (testTag "RestoreButton") + Wait for the restore to finish and tap "Get Started" (testTag "GetStartedButton") + Open the drawer menu, tap Subscriptions and tap the Allowances tab + Verify the payee's row (testTag "AllowanceRow-<entryId>") is listed as Active + Tap the payee's row and verify PAID AUTOMATICALLY (testTag "AllowancePaidSoFar") shows the total recorded before the reset, converted at the current rate + Close the sheet, tap the Payments tab and verify the "Restore" row (testTag "PaymentRequestRow-<paymentRequestId>") is listed with a subtitle ending in "· Auto-paid" + +