From f5f3c9713ad23ca0b49e02fffa4818336d559e5b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 02:26:15 +0100 Subject: [PATCH 01/11] fix: keep allowance history across a wallet restore --- .../models/PaykitAllowanceStateBackup.kt | 158 ++++++++++++++++++ .../bitkit/models/PaykitPaymentStateBackup.kt | 2 + .../java/to/bitkit/repositories/BackupRepo.kt | 8 + .../repositories/PaykitAllowanceStore.kt | 25 +++ .../models/PaykitAllowanceStateBackupTest.kt | 103 ++++++++++++ .../to/bitkit/repositories/BackupRepoTest.kt | 37 ++++ .../repositories/PaykitAllowanceStoreTest.kt | 53 ++++++ 7 files changed, 386 insertions(+) create mode 100644 app/src/main/java/to/bitkit/models/PaykitAllowanceStateBackup.kt create mode 100644 app/src/test/java/to/bitkit/models/PaykitAllowanceStateBackupTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/PaykitAllowanceStoreTest.kt 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..8df18805cc --- /dev/null +++ b/app/src/main/java/to/bitkit/models/PaykitAllowanceStateBackup.kt @@ -0,0 +1,158 @@ +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 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?, + 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(), + 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 cf99346ba0..94100c2df6 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -60,6 +60,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 @@ -124,6 +125,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 transferRepo: Provider, @@ -389,6 +391,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, @@ -770,6 +773,9 @@ class BackupRepo @Inject constructor( activeOnchainAttempt = active, usdt = paykitUsdtPaymentRepo.get().backupSnapshot(), acceptedOneTimeRequests = paykitPresentationStore.acceptedOneTimeBackupSnapshot(), + allowances = paykitAllowanceStore.backupSnapshot() + .mapValues { PaykitAllowanceStateBackup(it.value) } + .ifEmpty { null }, ), ) @@ -920,6 +926,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) @@ -943,6 +950,7 @@ class BackupRepo @Inject constructor( paykitPaymentProofRepo.get().restoreBackup(it.pendingProofs) it.usdt?.let { usdt -> paykitUsdtPaymentRepo.get().restoreBackup(usdt) } 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/PaykitAllowanceStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt index 92a9f62c87..3ea87ca9c3 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,26 @@ 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. */ + fun backupSnapshot(): Map = loadAll().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/test/java/to/bitkit/models/PaykitAllowanceStateBackupTest.kt b/app/src/test/java/to/bitkit/models/PaykitAllowanceStateBackupTest.kt new file mode 100644 index 0000000000..a89241a148 --- /dev/null +++ b/app/src/test/java/to/bitkit/models/PaykitAllowanceStateBackupTest.kt @@ -0,0 +1,103 @@ +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 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 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) + } + + 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 5e192dbd06..e628c596c5 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -59,6 +59,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 @@ -107,6 +108,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 transferRepo = mock() @@ -178,6 +180,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)) @@ -629,6 +632,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() @@ -2070,6 +2105,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()) } @@ -2098,6 +2134,7 @@ class BackupRepoTest : BaseUnitTest() { paykitPaymentProofStore = paykitPaymentProofStore, paykitPaymentRequestRepo = Provider { paykitPaymentRequestRepo }, paykitPresentationStore = paykitPresentationStore, + paykitAllowanceStore = paykitAllowanceStore, keychain = keychain, onchainSendAttemptStore = onchainSendAttemptStore, transferRepo = Provider { transferRepo }, 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..da624177db --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceStoreTest.kt @@ -0,0 +1,53 @@ +package to.bitkit.repositories + +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 + +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 `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) + } +} From 24a46e61f43fb7cc14fd8cce55267cb423c7de6c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 05:06:29 +0100 Subject: [PATCH 02/11] fix: restore an allowance backup that omits optional fields --- .../bitkit/models/PaykitAllowanceStateBackup.kt | 2 +- .../models/PaykitAllowanceStateBackupTest.kt | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/models/PaykitAllowanceStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitAllowanceStateBackup.kt index 8df18805cc..0918bb1801 100644 --- a/app/src/main/java/to/bitkit/models/PaykitAllowanceStateBackup.kt +++ b/app/src/main/java/to/bitkit/models/PaykitAllowanceStateBackup.kt @@ -50,7 +50,7 @@ data class PaykitAllowanceStateBackup( val attemptId: String, val isAutomatic: Boolean, val requestId: PaykitPaymentRequestId, - val allowanceId: String?, + val allowanceId: String? = null, val amountSats: ULong, val paymentEndpointIdentifier: String, val paymentHash: String? = null, diff --git a/app/src/test/java/to/bitkit/models/PaykitAllowanceStateBackupTest.kt b/app/src/test/java/to/bitkit/models/PaykitAllowanceStateBackupTest.kt index a89241a148..698818eae6 100644 --- a/app/src/test/java/to/bitkit/models/PaykitAllowanceStateBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/PaykitAllowanceStateBackupTest.kt @@ -59,6 +59,22 @@ class PaykitAllowanceStateBackupTest { 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\"") From da7953617b85e227ca2bfa47386543a82ef0f09d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 06:04:02 +0100 Subject: [PATCH 03/11] fix: never back up unreadable allowance state as empty --- .../to/bitkit/repositories/PaykitAllowanceStore.kt | 10 ++++++++-- .../to/bitkit/repositories/PaykitAllowanceStoreTest.kt | 10 ++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt index 3ea87ca9c3..e33b50956c 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt @@ -103,8 +103,14 @@ class PaykitAllowanceStore @Inject constructor( if (previous?.withoutWatermark() != state.withoutWatermark()) _backupStateVersion.update { it + 1 } } - /** Every identity's allowance state, for the wallet backup. */ - fun backupSnapshot(): Map = loadAll().statesByIdentity + /** + * 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) { diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceStoreTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceStoreTest.kt index da624177db..9d1a416b57 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceStoreTest.kt @@ -1,5 +1,6 @@ package to.bitkit.repositories +import kotlinx.serialization.SerializationException import org.junit.Before import org.junit.Test import org.mockito.kotlin.any @@ -9,6 +10,7 @@ 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 @@ -35,6 +37,14 @@ class PaykitAllowanceStoreTest : BaseUnitTest() { 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 From 8c8149372faa957f7dfe68882b21e799f3793493 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 06:20:10 +0100 Subject: [PATCH 04/11] fix: rebuild auto-paid totals from the allowance ledger on recovery --- .../repositories/PaykitAllowanceExecutor.kt | 42 ++++++++++++++++--- .../PaykitAllowanceExecutorTest.kt | 28 +++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index 83a2546e78..80cd639018 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 @@ -273,6 +274,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 }) { @@ -395,7 +398,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)) { @@ -953,16 +960,41 @@ 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 { // 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.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 isCounted = state.autoPaidRequestIds.any { + it.paymentRequestId == requestId.paymentRequestId && + PubkyPublicKeyFormat.matches(it.counterparty, requestId.counterparty) + } + val paid = occurrence.attempts.firstOrNull { + it.mode == PaymentExecutionMode.AUTOMATIC && it.status == PaymentExecutionStatus.SUCCEEDED + } + val amountSats = paid?.amount?.value()?.let(PaykitAllowance::satsFromBitcoinAmount) + if (isCounted || 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/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt index c835e91172..78d36ecc61 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -826,6 +826,34 @@ 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 `recovery gives the payment list back when it fails an automatic attempt`() = test { val request = fixtures.paymentRequest() From 652a95cf270ee7d7f474b9eec76bda7c62728bc6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 06:42:32 +0100 Subject: [PATCH 05/11] fix: count an automatic allowance payment once across journal and ledger --- .../repositories/PaykitAllowanceExecutor.kt | 13 ++++++++----- .../PaykitAllowanceExecutorTest.kt | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index 80cd639018..105887705c 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -965,6 +965,8 @@ private fun PaykitAllowanceLocalState.withAutoPaid( 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(requestId.counterparty, allowanceId) val total = (autoPaidSatsByAllowance[key] ?: 0uL).safe() + amountSats.safe() @@ -974,21 +976,22 @@ private fun PaykitAllowanceLocalState.withAutoPaid( ) } +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 isCounted = state.autoPaidRequestIds.any { - it.paymentRequestId == requestId.paymentRequestId && - PubkyPublicKeyFormat.matches(it.counterparty, requestId.counterparty) - } val paid = occurrence.attempts.firstOrNull { it.mode == PaymentExecutionMode.AUTOMATIC && it.status == PaymentExecutionStatus.SUCCEEDED } val amountSats = paid?.amount?.value()?.let(PaykitAllowance::satsFromBitcoinAmount) - if (isCounted || paid == null || amountSats == null) { + if (paid == null || amountSats == null) { state } else { state.withAutoPaid(requestId, paid.allowanceId, amountSats) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt index 78d36ecc61..5c23f5a217 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -854,6 +854,25 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { ) } + @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 gives the payment list back when it fails an automatic attempt`() = test { val request = fixtures.paymentRequest() From 2af0313127852b35d744b4c458a1b8a9dc23a3e1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 10:11:29 +0100 Subject: [PATCH 06/11] fix: let the node settle a restored allowance attempt --- .../repositories/PaykitAllowanceExecutor.kt | 10 ++++++++-- .../PaykitAllowanceExecutorTest.kt | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index 105887705c..88713eecc4 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -358,8 +358,14 @@ 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 -> 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 diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt index 5c23f5a217..b0ee1da521 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -45,6 +45,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 @@ -873,6 +874,24 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { ) } + @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 `recovery gives the payment list back when it fails an automatic attempt`() = test { val request = fixtures.paymentRequest() From 2b45d6b70ba303d2c3679fbd57406daee9f608ff Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 10:11:30 +0100 Subject: [PATCH 07/11] docs: add a journey for allowance history after a restore --- journeys/allowances/README.md | 1 + journeys/allowances/restore-keeps-history.xml | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 journeys/allowances/restore-keeps-history.xml diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md index e438e131ed..9a9a9db75e 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" + + From 411385d36869a4b42c3feb27f70b9f27dc4dca12 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 11:13:51 +0100 Subject: [PATCH 08/11] fix: wait for the node before settling a restored allowance attempt --- .../repositories/PaykitAllowanceExecutor.kt | 30 +++++++++++++++---- .../repositories/PaykitAllowanceRepo.kt | 6 +++- .../PaykitAllowanceExecutorTest.kt | 26 ++++++++++++++++ .../repositories/PaykitAllowanceRepoTest.kt | 11 +++++++ 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index 3d04064158..1831659325 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -125,6 +125,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() } @@ -287,8 +291,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,10 +377,14 @@ class PaykitAllowanceExecutor @Inject constructor( 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 -> when (entry.paymentHash?.let { payer.lightningPaymentStatus(it) }) { - PaymentStatus.SUCCEEDED -> PaymentOutcome.SUCCEEDED - PaymentStatus.PENDING -> null - PaymentStatus.FAILED, null -> PaymentOutcome.FAILED + 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 -> { @@ -389,6 +401,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) { diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt index 4d37012615..04b3361960 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() + } } } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt index e4e51d2893..bc3af1d632 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -101,6 +101,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 +228,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 @@ -917,6 +919,30 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { 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() 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) } From fbbfcfc0b0e2f5a747641ea5ad6a56f954a7f68c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 18:51:18 +0100 Subject: [PATCH 09/11] fix: keep a payment list's owner in the wallet backup --- .../bitkit/repositories/PrivatePaykitModels.kt | 1 + .../to/bitkit/repositories/PrivatePaykitRepo.kt | 14 +++++++++++--- .../bitkit/repositories/PrivatePaykitRepoTest.kt | 16 ++++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) 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 51aced845d..c9f0729524 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -847,6 +847,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) @@ -866,9 +870,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/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 24ecdbfc8a..00be1439ca 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -4084,6 +4084,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)) } From d6ba1c983d6363b6fa8fcf94d45d85c41b87f991 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 28 Oct 2026 09:20:35 +0100 Subject: [PATCH 10/11] fix: write allowance journal stages in ascii --- .../bitkit/models/PaykitAllowanceStateBackup.kt | 3 ++- .../models/PaykitAllowanceStateBackupTest.kt | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/models/PaykitAllowanceStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitAllowanceStateBackup.kt index 0918bb1801..b4cb70feee 100644 --- a/app/src/main/java/to/bitkit/models/PaykitAllowanceStateBackup.kt +++ b/app/src/main/java/to/bitkit/models/PaykitAllowanceStateBackup.kt @@ -8,6 +8,7 @@ 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 @@ -83,7 +84,7 @@ data class PaykitAllowanceStateBackup( paymentHash = it.paymentHash, onchainAddress = it.onchainAddress, transactionId = it.transactionId, - stage = it.stage.name.lowercase(), + stage = it.stage.name.lowercase(Locale.ROOT), createdAt = isoTime(it.createdAtMillis), paymentListVersion = it.paymentListVersion, previousPaymentListVersion = it.previousPaymentListVersion, diff --git a/app/src/test/java/to/bitkit/models/PaykitAllowanceStateBackupTest.kt b/app/src/test/java/to/bitkit/models/PaykitAllowanceStateBackupTest.kt index 698818eae6..6888d9781b 100644 --- a/app/src/test/java/to/bitkit/models/PaykitAllowanceStateBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/PaykitAllowanceStateBackupTest.kt @@ -9,6 +9,7 @@ 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 { @@ -84,6 +85,21 @@ class PaykitAllowanceStateBackupTest { 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( From f0eaf480039e12f401290828c6838d51f77a21eb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 28 Oct 2026 19:33:28 +0100 Subject: [PATCH 11/11] fix: journal a manual lightning hash before the send --- .../repositories/PaykitAllowanceExecutor.kt | 10 +++++-- .../repositories/PaykitAllowanceRepo.kt | 7 +++-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 5 +++- .../PaykitAllowanceExecutorTest.kt | 28 +++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 8 ++++-- 5 files changed, 50 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index 314b11aaa4..1752fe9c3d 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -738,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() @@ -758,7 +760,8 @@ class PaykitAllowanceExecutor @Inject constructor( request, amountSats, paymentEndpointIdentifier, - identity + paymentHash, + identity, ) } val error = result.exceptionOrNull() ?: return@withContext result @@ -771,6 +774,7 @@ class PaykitAllowanceExecutor @Inject constructor( request: PaykitPaymentRequest, amountSats: ULong, paymentEndpointIdentifier: String, + paymentHash: String?, identity: String, ): String? { ensureReconciled(identity) @@ -790,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. @@ -805,6 +809,7 @@ class PaykitAllowanceExecutor @Inject constructor( request: PaykitPaymentRequest, amountSats: ULong, paymentEndpointIdentifier: String, + paymentHash: String?, prepared: PaymentAttemptRecord, identity: String, ): String? { @@ -816,6 +821,7 @@ class PaykitAllowanceExecutor @Inject constructor( allowanceId = null, amountSats = amountSats, paymentEndpointIdentifier = paymentEndpointIdentifier, + paymentHash = paymentHash?.lowercase(), stage = Stage.PREPARED, createdAtMillis = clock.now().toEpochMilliseconds(), ), diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt index 04b3361960..3ff41b3c0e 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -587,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/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index e70308e635..9c7ef6664c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -5825,7 +5825,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/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt index b37c7ee0fd..f81083ab23 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -1196,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/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index ca8ea6fbf4..2e7dd09c03 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -792,7 +792,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) @@ -12670,7 +12670,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)) @@ -12825,7 +12826,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)