Skip to content
Open
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
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/data/CacheStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ data class AppCacheData(
val deletedActivities: List<String> = listOf(),
val pendingBoostActivities: List<PendingBoostActivity> = listOf(),
val backgroundReceive: NewTransactionSheetDetails? = null,
val addressSearchLastUsedReceiveIndexes: Map<String, Int> = mapOf(),
val addressSearchLastUsedChangeIndexes: Map<String, Int> = mapOf(),
) {
fun resetBip21() = copy(bip21 = "", bolt11 = "", onchainAddress = "")
}
146 changes: 146 additions & 0 deletions app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package to.bitkit.data

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.Serializer
import androidx.datastore.dataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.Serializable
import to.bitkit.di.json
import to.bitkit.utils.Logger
import java.io.InputStream
import java.io.OutputStream
import javax.inject.Inject
import javax.inject.Singleton

private val Context.privatePaykitCacheDataStore: DataStore<PrivatePaykitCacheData> by dataStore(
fileName = "private_paykit_cache.json",
serializer = PrivatePaykitCacheSerializer,
)

private val Context.privatePaykitReservationDataStore: DataStore<PrivatePaykitReservationData> by dataStore(
fileName = "private_paykit_reservations.json",
serializer = PrivatePaykitReservationSerializer,
)

@Singleton
class PrivatePaykitCacheStore @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val store = context.privatePaykitCacheDataStore

val data: Flow<PrivatePaykitCacheData> = store.data

suspend fun update(transform: (PrivatePaykitCacheData) -> PrivatePaykitCacheData) {
store.updateData(transform)
}

suspend fun reset() {
store.updateData { PrivatePaykitCacheData() }
}
}

@Singleton
class PrivatePaykitReservationStore @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val store = context.privatePaykitReservationDataStore

val data: Flow<PrivatePaykitReservationData> = store.data

suspend fun update(transform: (PrivatePaykitReservationData) -> PrivatePaykitReservationData) {
store.updateData(transform)
}

suspend fun reset() {
store.updateData { PrivatePaykitReservationData() }
}
}

@Serializable
data class PrivatePaykitCacheData(
val contacts: Map<String, PrivatePaykitContactCacheData> = emptyMap(),
val cleanupPending: Boolean = false,
val deletedContactCleanupPendingPublicKeys: Set<String> = emptySet(),
)

@Serializable
data class PrivatePaykitContactCacheData(
val remoteEndpoints: List<PrivatePaykitStoredPaymentEntryData> = emptyList(),
val localInvoice: PrivatePaykitStoredInvoiceData? = null,
val receivedInvoicePaymentHashes: List<String> = emptyList(),
val lastLocalPayloadHash: String? = null,
val linkCompletedAt: Long? = null,
val handshakeUpdatedAt: Long? = null,
val recoveryStartedAt: Long? = null,
val mainRecoveryAttemptId: String? = null,
val responderRecoveryAttemptId: String? = null,
val lastCompletedRecoveryAttemptId: String? = null,
val linkFailureCount: Int = 0,
)

@Serializable
data class PrivatePaykitStoredPaymentEntryData(
val methodId: String,
val endpointData: String,
)

@Serializable
data class PrivatePaykitStoredInvoiceData(
val bolt11: String,
val paymentHash: String,
val expiresAt: Long,
)

@Serializable
data class PrivatePaykitReservationData(
val version: Int = 1,
val reservedReceiveIndexesByAddressType: Map<String, Set<Int>> = emptyMap(),
val contactAssignments: Map<String, PrivatePaykitStoredAssignmentData> = emptyMap(),
val contactAssignmentHistory: Map<String, List<PrivatePaykitStoredAssignmentData>> = emptyMap(),
val restoredReservedReceiveIndexCeilingsByAddressType: Map<String, Int> = emptyMap(),
)

@Serializable
data class PrivatePaykitStoredAssignmentData(
val addressType: String,
val receiveIndex: Int,
val address: String = "",
)

private object PrivatePaykitCacheSerializer : Serializer<PrivatePaykitCacheData> {
private const val TAG = "PrivatePaykitCacheSerializer"

override val defaultValue: PrivatePaykitCacheData = PrivatePaykitCacheData()

override suspend fun readFrom(input: InputStream): PrivatePaykitCacheData =
runCatching {
json.decodeFromString<PrivatePaykitCacheData>(input.readBytes().decodeToString())
}.getOrElse {
Logger.error("Failed to deserialize PrivatePaykitCacheData", it, context = TAG)
defaultValue
}

override suspend fun writeTo(t: PrivatePaykitCacheData, output: OutputStream) {
output.write(json.encodeToString(t).encodeToByteArray())
}
}

private object PrivatePaykitReservationSerializer : Serializer<PrivatePaykitReservationData> {
private const val TAG = "PrivatePaykitReservationSerializer"

override val defaultValue: PrivatePaykitReservationData = PrivatePaykitReservationData()

override suspend fun readFrom(input: InputStream): PrivatePaykitReservationData =
runCatching {
json.decodeFromString<PrivatePaykitReservationData>(input.readBytes().decodeToString())
}.getOrElse {
Logger.error("Failed to deserialize PrivatePaykitReservationData", it, context = TAG)
defaultValue
}

override suspend fun writeTo(t: PrivatePaykitReservationData, output: OutputStream) {
output.write(json.encodeToString(t).encodeToByteArray())
}
}
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/data/keychain/Keychain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ class Keychain @Inject constructor(
PIN,
PIN_ATTEMPTS_REMAINING,
PAYKIT_SESSION,
PRIVATE_PAYKIT_SECRET_STATE,
PUBKY_SECRET_KEY,
}
}
Expand Down
15 changes: 15 additions & 0 deletions app/src/main/java/to/bitkit/models/BackupPayloads.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@ data class WalletBackupV1(
val version: Int = 1,
val createdAt: Long,
val transfers: List<TransferEntity>,
val privatePaykitHighestReservedReceiveIndexByAddressType: Map<String, Int>? = null,
val privatePaykitContactLinks: Map<String, PrivatePaykitContactLinkBackupV1>? = null,
)

@Serializable
data class PrivatePaykitContactLinkBackupV1(
val publicKey: String,
val linkSnapshotHex: String? = null,
val handshakeSnapshotHex: String? = null,
val remoteEndpoints: Map<String, String> = emptyMap(),
val linkCompletedAt: Long? = null,
val handshakeUpdatedAt: Long? = null,
val recoveryStartedAt: Long? = null,
val mainRecoveryAttemptId: String? = null,
val responderRecoveryAttemptId: String? = null,
)

@Serializable
Expand Down
90 changes: 74 additions & 16 deletions app/src/main/java/to/bitkit/repositories/BackupRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import to.bitkit.utils.Logger
import to.bitkit.utils.jsonLogOf
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
import kotlin.time.Clock
import kotlin.time.Duration.Companion.seconds
Expand Down Expand Up @@ -85,6 +86,8 @@ class BackupRepo @Inject constructor(
private val blocktankRepo: BlocktankRepo,
private val activityRepo: ActivityRepo,
private val pubkyRepo: PubkyRepo,
private val privatePaykitRepo: Provider<PrivatePaykitRepo>,
private val privatePaykitAddressReservationRepo: Provider<PrivatePaykitAddressReservationRepo>,
private val preActivityMetadataRepo: PreActivityMetadataRepo,
private val lightningService: LightningService,
private val clock: Clock,
Expand Down Expand Up @@ -279,6 +282,26 @@ class BackupRepo @Inject constructor(
}
dataListenerJobs.add(pubkyStateJob)

val privatePaykitStateJob = scope.launch {
privatePaykitRepo.get().backupStateVersion
.drop(1)
.collect {
if (shouldSkipBackup()) return@collect
markBackupRequired(BackupCategory.WALLET)
}
}
dataListenerJobs.add(privatePaykitStateJob)

val privatePaykitReservationJob = scope.launch {
privatePaykitAddressReservationRepo.get().backupStateVersion
.drop(1)
.collect {
if (shouldSkipBackup()) return@collect
markBackupRequired(BackupCategory.WALLET)
}
}
dataListenerJobs.add(privatePaykitReservationJob)

// BLOCKTANK - Observe blocktank state changes (orders, cjitEntries, info)
val blocktankJob = scope.launch {
blocktankRepo.blocktankState
Expand Down Expand Up @@ -438,7 +461,7 @@ class BackupRepo @Inject constructor(
cacheStore.updateBackupStatus(category) {
it.copy(running = false)
}
Logger.error("Backup failed for: '$category'", e = e, context = TAG)
Logger.error("Backup failed for: '$category'", e, context = TAG)
}
}

Expand All @@ -461,16 +484,7 @@ class BackupRepo @Inject constructor(
json.encodeToString(payload).toByteArray()
}

BackupCategory.WALLET -> {
val transfers = db.transferDao().getAll()

val payload = WalletBackupV1(
createdAt = currentTimeMillis(),
transfers = transfers
)

json.encodeToString(payload).toByteArray()
}
BackupCategory.WALLET -> getWalletBackupDataBytes()

BackupCategory.METADATA -> getMetadataBackupDataBytes()

Expand Down Expand Up @@ -520,6 +534,29 @@ class BackupRepo @Inject constructor(
json.encodeToString(payload).toByteArray()
}

private suspend fun getWalletBackupDataBytes(): ByteArray {
val transfers = db.transferDao().getAll()
val privateReservations = privatePaykitAddressReservationRepo.get().backupSnapshot()
.onFailure {
Logger.warn("Failed to snapshot private Paykit reservations", it, context = TAG)
}
.getOrThrow()
val privateLinks = privatePaykitRepo.get().backupSnapshot()
.onFailure {
Logger.warn("Failed to snapshot private Paykit contact links", it, context = TAG)
}
.getOrThrow()

val payload = WalletBackupV1(
createdAt = currentTimeMillis(),
transfers = transfers,
privatePaykitHighestReservedReceiveIndexByAddressType = privateReservations,
privatePaykitContactLinks = privateLinks,
)

return json.encodeToString(payload).toByteArray()
}

suspend fun performFullRestoreFromLatestBackup(
onCacheRestored: suspend () -> Unit = {},
): Result<Unit> = withContext(ioDispatcher) {
Expand Down Expand Up @@ -553,10 +590,7 @@ class BackupRepo @Inject constructor(
parsed.createdAt
}
performRestore(BackupCategory.WALLET) { dataBytes ->
val parsed = json.decodeFromString<WalletBackupV1>(String(dataBytes))
db.transferDao().upsert(parsed.transfers)
Logger.debug("Restored ${parsed.transfers.size} transfers", context = TAG)
parsed.createdAt
restoreWalletBackup(dataBytes)
}
performRestore(BackupCategory.BLOCKTANK) { dataBytes ->
val parsed = json.decodeFromString<BlocktankBackupV1>(String(dataBytes))
Expand All @@ -573,14 +607,38 @@ class BackupRepo @Inject constructor(
}.onSuccess {
settingsStore.update { it.copy(backupVerified = true) }
}.onFailure { e ->
Logger.warn("Full restore error", e = e, context = TAG)
Logger.warn("Full restore error", e, context = TAG)
}

_isRestoring.update { false }

return@withContext result
}

private suspend fun restoreWalletBackup(dataBytes: ByteArray): Long {
val parsed = json.decodeFromString<WalletBackupV1>(String(dataBytes))
db.transferDao().upsert(parsed.transfers)
if (!parsed.privatePaykitHighestReservedReceiveIndexByAddressType.isNullOrEmpty()) {
cacheStore.update { it.copy(onchainAddress = "", bip21 = "") }
}
privatePaykitAddressReservationRepo.get()
.restoreBackup(parsed.privatePaykitHighestReservedReceiveIndexByAddressType)
.onFailure {
Logger.warn("Failed to restore private Paykit reservations", it, context = TAG)
}
privatePaykitRepo.get().restoreBackup(parsed.privatePaykitContactLinks)
.onFailure {
Logger.warn("Failed to restore private Paykit contact links", it, context = TAG)
}
privatePaykitAddressReservationRepo.get()
.reconcileReservedIndexesWithLdk()
.onFailure {
Logger.warn("Failed to reconcile restored private Paykit reservations", it, context = TAG)
}
Logger.debug("Restored ${parsed.transfers.size} transfers", context = TAG)
return parsed.createdAt
}

suspend fun getLatestBackupTime(): ULong? = withContext(ioDispatcher) {
runCatching {
withTimeout(VSS_TIMESTAMP_TIMEOUT) {
Expand Down
Loading
Loading