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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 45 additions & 12 deletions app/src/main/java/to/bitkit/repositories/BackupRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.yield
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
Expand Down Expand Up @@ -69,6 +70,7 @@ import to.bitkit.services.CoreService
import to.bitkit.services.LightningService
import to.bitkit.services.PaykitSdkService
import to.bitkit.ui.shared.toast.ToastEventBus
import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
import to.bitkit.utils.NetworkValidationHelper
import to.bitkit.utils.jsonLogOf
Expand Down Expand Up @@ -583,7 +585,7 @@ class BackupRepo @Inject constructor(
private suspend fun markBackupFailed(category: BackupCategory, backupRequired: Long, e: Throwable) {
withContext(NonCancellable) {
cacheStore.updateBackupStatus(category) {
if (it.required == backupRequired) {
if (it.required == backupRequired && e !is WalletBackupSnapshotChanged) {
failedBackupRequired[category] = backupRequired
} else {
failedBackupRequired -= category
Expand All @@ -592,7 +594,11 @@ class BackupRepo @Inject constructor(
}
runningBackups -= category
}
Logger.error("Backup failed for: '$category'", e, context = TAG)
if (e is WalletBackupSnapshotChanged) {
Logger.debug("Deferred changing backup snapshot for: '$category'", context = TAG)
} else {
Logger.error("Backup failed for: '$category'", e, context = TAG)
}
}

private suspend fun getBackupDataBytes(category: BackupCategory): ByteArray = when (category) {
Expand Down Expand Up @@ -679,15 +685,32 @@ class BackupRepo @Inject constructor(
}

private suspend fun getWalletBackupDataBytes(usdt: String? = null): ByteArray {
val sdkGeneration = paykitSdkService.runtimeGeneration
var attemptsRemaining = WALLET_SNAPSHOT_ATTEMPTS
while (true) {
check(paykitSdkService.runtimeGeneration == sdkGeneration) {
"Paykit identity changed during wallet snapshot"
}
val snapshot = runSuspendCatching { captureWalletBackupDataBytes(usdt, sdkGeneration) }
attemptsRemaining--
if (snapshot.exceptionOrNull() !is WalletBackupSnapshotChanged || attemptsRemaining == 0) {
return snapshot.getOrThrow()
}
yield()
}
}

private suspend fun captureWalletBackupDataBytes(usdt: String?, sdkGeneration: Long): ByteArray {
check(!keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)) {
"Wallet backup restore is incomplete"
}
lateinit var transfers: List<TransferEntity>
val privateReservations = privatePaykitAddressReservationRepo.get().backupSnapshot().getOrThrow()
val privateRepo = privatePaykitRepo.get()
val privateVersion = privateRepo.backupStateVersion.value
val sdkVersion = paykitSdkService.backupStateVersion.value
val paykitSdkBackupState = privateRepo.backupSnapshot().getOrThrow()
val reservationRepo = privatePaykitAddressReservationRepo.get()
val reservationVersion = reservationRepo.backupStateVersion.value
val privateReservations = reservationRepo.backupSnapshot().getOrThrow()

val watchOnlyAccountSnapshot = watchOnlyAccountStore.backupSnapshot()
val walletIndex = backupWalletIndex()
Expand All @@ -705,37 +728,41 @@ class BackupRepo @Inject constructor(
it.originalInputs == null && it.candidateTxids.isEmpty()
} != true
) { "Waiting for signed receipt before wallet backup" }
check(
privateRepo.backupStateVersion.value == privateVersion &&
paykitSdkService.backupStateVersion.value == sdkVersion
) { "Private payment state changed during wallet snapshot" }
val proofs = snapshotProofs
val active = snapshotAttempt?.let { attempt ->
val wire = ActiveOnchainAttemptBackup.from(
attempt,
Env.network.name.lowercase(),
vssStoreIdProvider.getBackupWalletBinding(walletIndex),
)
validateActiveProof(wire, proofs)
validateActiveProof(wire, snapshotProofs)
wire
}
val payload = WalletBackupV1(
createdAt = currentTimeMillis(),
transfers = transfers,
privatePaykitHighestReservedReceiveIndexByAddressType = privateReservations,
paykitSdkBackupState = paykitSdkBackupState,
paykitSdkBackupState = paykitSdkBackupState.state,
watchOnlyAccounts = watchOnlyAccountSnapshot.accounts,
watchOnlyAccountAllocationState = watchOnlyAccountSnapshot.allocationState,
usdtWallet = usdt ?: usdtRepo.get().backupSnapshot(),
paykitPaymentState = PaykitPaymentStateBackup(
subscriptions = paykitPresentationStore.backupSnapshot(),
pendingProofs = proofs,
pendingProofs = snapshotProofs,
activeOnchainAttempt = active,
usdt = paykitUsdtPaymentRepo.get().backupSnapshot(),
acceptedOneTimeRequests = paykitPresentationStore.acceptedOneTimeBackupSnapshot(),
),
)

check(paykitSdkService.runtimeGeneration == sdkGeneration) {
"Paykit identity changed during wallet snapshot"
}
if (privateRepo.backupStateVersion.value != privateVersion ||
paykitSdkService.backupStateVersion.value != paykitSdkBackupState.sdkVersion ||
reservationRepo.backupStateVersion.value != reservationVersion
) {
throw WalletBackupSnapshotChanged()
}
return json.encodeToString(payload).toByteArray()
}

Expand Down Expand Up @@ -1080,6 +1107,10 @@ class BackupRepo @Inject constructor(

private const val MINUTE_IN_MS = 60_000
private const val BACKUP_DEBOUNCE = 5000L // 5 seconds

/** Maximum captures before deferring a changing wallet to the next scheduled backup. */
private const val WALLET_SNAPSHOT_ATTEMPTS = 3

private const val BACKUP_CHECK_INTERVAL = 60 * 1000L // 1 minute
private const val FAILED_BACKUP_CHECK_TIME = 30 * 60 * 1000L // 30 minutes
private const val FAILED_BACKUP_NOTIFICATION_INTERVAL = 10 * 60 * 1000L // 10 minutes
Expand All @@ -1094,6 +1125,8 @@ class BackupRepo @Inject constructor(
}
}

internal class WalletBackupSnapshotChanged : AppError("Private payment state changed during wallet snapshot")

/**
* Result of handing a backup envelope's Core-owned fields to Core for migration.
*
Expand Down
16 changes: 11 additions & 5 deletions app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -697,20 +697,24 @@ class PrivatePaykitRepo @Inject constructor(
}
}

suspend fun backupSnapshot(): Result<String?> =
internal suspend fun backupSnapshot(): Result<PrivatePaykitBackupSnapshot> =
withContext(serializedDispatcher) {
runSuspendCatching {
pubkyService.currentPublicKey() ?: return@runSuspendCatching null
json.encodeToString(
val sdkVersion = paykitSdkService.backupStateVersion.value
pubkyService.currentPublicKey()
?: return@runSuspendCatching PrivatePaykitBackupSnapshot(null, sdkVersion)
// This snapshot can be required before broadcast; it must not wait for payment to finish.
val exported = paykitSdkService.exportBackupState(Priority.Interactive)
val state = json.encodeToString(
PrivatePaykitBackup(
// This snapshot can be required before broadcast; it must not wait for payment to finish.
sdkState = paykitSdkService.exportBackupState(Priority.Interactive),
sdkState = exported.state,
consumedPrivatePaymentListVersions = ensureState().contacts
.mapNotNull { (publicKey, contactState) ->
contactState.consumedPrivatePaymentListVersion?.let { publicKey to it }
}.toMap(),
)
)
PrivatePaykitBackupSnapshot(state, exported.version)
}
}

Expand Down Expand Up @@ -2262,3 +2266,5 @@ class PrivatePaykitRepo @Inject constructor(
_backupStateVersion.update { it + 1 }
}
}

internal data class PrivatePaykitBackupSnapshot(val state: String?, val sdkVersion: Long)
12 changes: 8 additions & 4 deletions app/src/main/java/to/bitkit/services/PaykitSdkService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,8 @@ class PaykitSdkService @Inject constructor(
private var sdk: PaykitSdk? = null

@Volatile
private var runtimeGeneration = 0L
internal var runtimeGeneration = 0L
private set

private var cachedPaykitKey: PaykitKeyGeneration? = null
private var cachedBackupState: PaykitBackupStateSnapshot? = null
Expand Down Expand Up @@ -1295,17 +1296,18 @@ class PaykitSdkService @Inject constructor(
},
)

internal suspend fun exportBackupState(priority: Priority = Priority.Background): String {
internal suspend fun exportBackupState(priority: Priority = Priority.Background): PaykitSdkBackupExport {
isSetup.await()
val generation = runtimeGeneration
return operationLock.withoutLock {
var backup: String?
var backup: PaykitSdkBackupExport?
do {
if (priority == Priority.Background) isPaymentSubmissionActive.first { !it }
backup = operationLock.withLock(priority) {
check(runtimeGeneration == generation) { "Paykit runtime changed before backup export" }
if (priority == Priority.Background && isPaymentSubmissionActive.value) return@withLock null
withPaykitKey { completeSdkCall { it.exportBackupString() } }
val state = withPaykitKey { completeSdkCall { it.exportBackupString() } }
PaykitSdkBackupExport(state, backupStateVersion.value)
}
} while (backup == null)
backup
Expand Down Expand Up @@ -1628,6 +1630,8 @@ private data class PaykitKeyGeneration(val publicKey: String, val generation: UL

internal data class PaykitBackupStateSnapshot(val stateRevision: String, val backupRevision: String)

internal data class PaykitSdkBackupExport(val state: String, val version: Long)

@Suppress("LongParameterList")
internal suspend fun <T> withPaykitBackupStateTracking(
readRevision: suspend () -> String,
Expand Down
Loading
Loading