Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4d86489
fix: decouple wallet restore from profile refresh
ben-kaufman Oct 24, 2026
ac2a7c6
docs: associate restore note with PR 1686
ben-kaufman Oct 24, 2026
d4ba907
fix: preserve active identity during scheduled restore setup
ben-kaufman Oct 26, 2026
0fd5e42
Merge validated connection recovery into deferred identity restoration
ben-kaufman Oct 26, 2026
0f66cc3
merge validated restore admission parent
ben-kaufman Oct 28, 2026
89e2702
Merge current required-recovery parent
ben-kaufman Oct 28, 2026
9ac9746
Retry missing profile enrichment after connectivity recovery
ben-kaufman Oct 28, 2026
9ce4302
Merge validated current master into identity recovery
ben-kaufman Oct 28, 2026
5ae48f9
Merge branch 'master' into fix/restore-identity-background
ovitrif Oct 29, 2026
217b42e
Merge current master into identity recovery
ben-kaufman Oct 29, 2026
78a0ff7
Merge current parent guards into restored identity recovery
ben-kaufman Oct 30, 2026
71fa609
Merge current master into deferred identity restoration
ben-kaufman Oct 31, 2026
7b795fd
fix: stop queued allowance offers after deactivation
ben-kaufman Nov 1, 2026
78269d8
Preserve active allowance ownership during contact refresh
ben-kaufman Nov 1, 2026
1b1426f
fix: preserve pending profile setup and revoke stale activation
ben-kaufman Nov 1, 2026
181d664
fix: resume allowances after a paused activation
ben-kaufman Nov 1, 2026
b6751e2
test: report Pubky alias state on routing failures
ben-kaufman Oct 30, 2026
43e0f3f
merge: integrate current wallet restore contracts
ben-kaufman Nov 3, 2026
4056d2c
fix: keep VSS operations with their original wallet
ben-kaufman Nov 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/src/main/java/to/bitkit/data/WalletConnectionRecovery.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import to.bitkit.data.backup.VssClientCoordinator
import to.bitkit.data.backup.VssStoreIdProvider
import to.bitkit.data.keychain.Keychain
import to.bitkit.data.keychain.hasPendingWalletRestore
Expand All @@ -30,6 +31,7 @@ class WalletConnectionRecovery @Inject constructor(
private val db: AppDb,
private val storeIdProvider: VssStoreIdProvider,
private val settingsStore: SettingsStore,
private val vssClients: VssClientCoordinator,
) {
companion object {
internal const val KEY = "WALLET_CONNECTION_RECOVERY"
Expand Down Expand Up @@ -130,7 +132,7 @@ class WalletConnectionRecovery @Inject constructor(
currentCoroutineContext().ensureActive()
beginWipe()
currentCoroutineContext().ensureActive()
block()
vssClients.withSuspendedClients { block() }
} finally {
storeIdProvider.clearCache()
endWipe()
Expand Down
139 changes: 66 additions & 73 deletions app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,58 +3,54 @@ package to.bitkit.data.backup
import com.synonym.vssclient.KeyVersion
import com.synonym.vssclient.VssItem
import com.synonym.vssclient.vssDelete
import com.synonym.vssclient.vssDeriveStoreId
import com.synonym.vssclient.vssGet
import com.synonym.vssclient.vssListKeys
import com.synonym.vssclient.vssNewClientWithLnurlAuth
import com.synonym.vssclient.vssStore
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import to.bitkit.data.keychain.Keychain
import to.bitkit.di.IoDispatcher
import to.bitkit.env.Env
import to.bitkit.ext.runSuspendCatching
import to.bitkit.utils.Logger
import to.bitkit.utils.ServiceError
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration.Companion.seconds

class MnemonicNotAvailableException : Exception("Mnemonic not available")

@Singleton
class VssBackupClient @Inject constructor(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val vssStoreIdProvider: VssStoreIdProvider,
private val coordinator: VssClientCoordinator,
private val keychain: Keychain,
) {
@Volatile
private var isSetup = CompletableDeferred<Unit>()
private val setupMutex = Mutex()

suspend fun setup(walletIndex: Int = 0): Result<Unit> = withContext(ioDispatcher) {
setupMutex.withLock {
val gate = isSetup
runCatching {
if (gate.isCompleted && !gate.isCancelled) return@runCatching

runCatching {
coordinator.setup(VssClientCoordinator.Domain.APP) {
val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)
?: throw MnemonicNotAvailableException()

withTimeout(30.seconds) {
Logger.debug("VSS client setting up…", context = TAG)
val vssUrl = Env.vssServerUrl
val lnurlAuthServerUrl = Env.lnurlAuthServerUrl
val vssStoreId = vssStoreIdProvider.getVssStoreId(walletIndex)
Logger.verbose("Building VSS client with vssUrl: '$vssUrl'", context = TAG)
Logger.verbose("Building VSS client with lnurlAuthServerUrl: '$lnurlAuthServerUrl'", context = TAG)
if (lnurlAuthServerUrl.isBlank()) {
throw ServiceError.VssAuthRequired()
}
val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)
val vssStoreId = vssDeriveStoreId(Env.vssStoreIdPrefix, mnemonic, passphrase)

vssNewClientWithLnurlAuth(
baseUrl = vssUrl,
Expand All @@ -63,14 +59,13 @@ class VssBackupClient @Inject constructor(
passphrase = passphrase,
lnurlAuthServerUrl = lnurlAuthServerUrl,
)
gate.complete(Unit)
Logger.info("VSS client setup with server: '$vssUrl'", context = TAG)
Logger.info("VSS client setup for wallet[$walletIndex] with server: '$vssUrl'", context = TAG)
}
}.onFailure {
gate.completeExceptionally(it)
if (isSetup === gate) isSetup = CompletableDeferred()
Logger.error("VSS client setup error", it, context = TAG)
}
}.onFailure {
currentCoroutineContext().ensureActive()
if (it is CancellationException && it !is TimeoutCancellationException) throw it
Logger.error("VSS client setup error", it, context = TAG)
}
}

Expand All @@ -85,49 +80,43 @@ class VssBackupClient @Inject constructor(
baseDelayMs: Long = 1000L,
logger: SetupRetryLogger.() -> Unit,
): Result<Unit> = withContext(ioDispatcher) {
val log = SetupRetryLogger().apply(logger)
var attempt = 0
while (attempt < maxAttempts) {
val result = setup()
if (result.isSuccess) {
log.onSuccess(attempt + 1)
return@withContext Result.success(Unit)
}
val exception = result.exceptionOrNull()
if (exception != null && exception !is MnemonicNotAvailableException) {
return@withContext result
}
attempt++
if (attempt < maxAttempts) {
val delayMs = baseDelayMs * attempt
log.onRetry(attempt, maxAttempts, delayMs)
delay(delayMs)
coordinator.withWallet {
val log = SetupRetryLogger().apply(logger)
var attempt = 0
while (attempt < maxAttempts) {
val result = setup()
if (result.isSuccess) {
log.onSuccess(attempt + 1)
return@withWallet Result.success(Unit)
}
val exception = result.exceptionOrNull()
if (exception != null && exception !is MnemonicNotAvailableException) {
return@withWallet result
}
attempt++
if (attempt < maxAttempts) {
val delayMs = baseDelayMs * attempt
log.onRetry(attempt, maxAttempts, delayMs)
delay(delayMs)
}
}
log.onExhausted(maxAttempts)
Result.failure(MnemonicNotAvailableException())
}
log.onExhausted(maxAttempts)
Result.failure(MnemonicNotAvailableException())
}

fun reset() {
synchronized(this) {
isSetup.cancel()
isSetup = CompletableDeferred()
}
vssStoreIdProvider.clearCache()
Logger.debug("VSS client reset", context = TAG)
}

suspend fun putObject(
key: String,
data: ByteArray,
): Result<VssItem> = withContext(ioDispatcher) {
isSetup.await()
Logger.verbose("VSS 'putObject' call for '$key'", context = TAG)
runCatching {
vssStore(
key = key,
value = data,
)
runSuspendCatching {
coordinator.withClient(VssClientCoordinator.Domain.APP) {
vssStore(
key = key,
value = data,
)
}
}.onSuccess {
Logger.verbose("VSS 'putObject' success for '$key' at version: '${it.version}'", context = TAG)
}.onFailure {
Expand All @@ -136,12 +125,13 @@ class VssBackupClient @Inject constructor(
}

suspend fun getObject(key: String): Result<VssItem?> = withContext(ioDispatcher) {
isSetup.await()
Logger.verbose("VSS 'getObject' call for '$key'", context = TAG)
runCatching {
vssGet(
key = key,
)
runSuspendCatching {
coordinator.withClient(VssClientCoordinator.Domain.APP) {
vssGet(
key = key,
)
}
}.onSuccess {
if (it == null) {
Logger.verbose("VSS 'getObject' success null for '$key'", context = TAG)
Expand All @@ -154,10 +144,11 @@ class VssBackupClient @Inject constructor(
}

suspend fun listKeys(prefix: String? = null): Result<List<KeyVersion>> = withContext(ioDispatcher) {
isSetup.await()
Logger.verbose("VSS 'listKeys' call with prefix: '$prefix'", context = TAG)
runCatching {
vssListKeys(prefix = prefix)
runSuspendCatching {
coordinator.withClient(VssClientCoordinator.Domain.APP) {
vssListKeys(prefix = prefix)
}
}.onSuccess {
Logger.verbose("VSS 'listKeys' success - found ${it.size} key(s)", context = TAG)
}.onFailure {
Expand All @@ -166,10 +157,11 @@ class VssBackupClient @Inject constructor(
}

suspend fun deleteObject(key: String): Result<Boolean> = withContext(ioDispatcher) {
isSetup.await()
Logger.verbose("VSS 'deleteObject' call for '$key'", context = TAG)
runCatching {
vssDelete(key = key)
runSuspendCatching {
coordinator.withClient(VssClientCoordinator.Domain.APP) {
vssDelete(key = key)
}
}.onSuccess { wasDeleted ->
if (wasDeleted) {
Logger.verbose("VSS 'deleteObject' success for '$key' - key was deleted", context = TAG)
Expand All @@ -182,16 +174,17 @@ class VssBackupClient @Inject constructor(
}

suspend fun deleteAllKeys(): Result<Int> = withContext(ioDispatcher) {
isSetup.await()
Logger.verbose("VSS 'deleteAllKeys' call", context = TAG)
runCatching {
val keys = vssListKeys(prefix = null)
var deletedCount = 0
for (keyVersion in keys) {
val wasDeleted = vssDelete(key = keyVersion.key)
if (wasDeleted) deletedCount++
runSuspendCatching {
coordinator.withClient(VssClientCoordinator.Domain.APP) {
val keys = vssListKeys(prefix = null)
var deletedCount = 0
for (keyVersion in keys) {
val wasDeleted = vssDelete(key = keyVersion.key)
if (wasDeleted) deletedCount++
}
deletedCount
}
deletedCount
}.onSuccess {
Logger.verbose("VSS 'deleteAllKeys' success - deleted $it key(s)", context = TAG)
}.onFailure {
Expand Down
Loading
Loading