diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b0140d5d77..9e10fd8dae 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -363,6 +363,10 @@ androidComponents { buildConfigFields.put("E2E_HOMEGATE_URL", e2eHomegateUrlEnv.stringField()) buildConfigFields.put("E2E_HOMESERVER_PUBKY", e2eHomeserverPubkyEnv.stringField()) buildConfigFields.put("E2E_HOMEGATE_OVERRIDE", e2eHomegateOverrideEnv.stringField()) + for (key in listOf("E2E_VSS_SERVER_URL", "E2E_LNURL_AUTH_SERVER_URL")) { + val value = if (variant.buildType == "debug") localProp(key).orElse("") else provider { "" } + buildConfigFields.put(key, value.stringField()) + } buildConfigFields.put("TREZOR_BRIDGE", trezorBridgeEnv.booleanField()) buildConfigFields.put("TREZOR_BRIDGE_URL", trezorBridgeUrlEnv.stringField()) buildConfigFields.put("GEO", geoEnv.booleanField()) diff --git a/app/src/androidTest/java/to/bitkit/services/UsdtNativeRecoveryIntegrationTest.kt b/app/src/androidTest/java/to/bitkit/services/UsdtNativeRecoveryIntegrationTest.kt new file mode 100644 index 0000000000..979d271084 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/services/UsdtNativeRecoveryIntegrationTest.kt @@ -0,0 +1,229 @@ +package to.bitkit.services + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.synonym.bitkitcore.UsdtBackup +import com.synonym.bitkitcore.UsdtDestination +import com.synonym.bitkitcore.UsdtException +import com.synonym.bitkitcore.UsdtWallet +import com.synonym.bitkitcore.usdtAddress +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.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import to.bitkit.data.backup.UsdtRecoveryBackupStore +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PaykitUsdtStateBackup +import to.bitkit.test.annotations.DeviceIntegration +import java.io.File +import java.net.HttpURLConnection +import java.net.URI +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Opt-in protocol check with a public seed, isolated databases and real encrypted VSS transport. */ +@RunWith(AndroidJUnit4::class) +@DeviceIntegration +class UsdtNativeRecoveryIntegrationTest { + companion object { + private const val FIXTURE = "http://127.0.0.1:18557" + private const val MNEMONIC = "test test test test test test test test test test test junk" + } + + @Test + fun encryptedRecoveryAgainstLocalProtocolFixture(): Unit = runBlocking { + withTimeout(180_000) { + val driver = runCatching { request("$FIXTURE/android-driver/control") }.getOrNull() + assumeTrue("Requires the owned local recovery protocol fixture", driver != null) + val scenario = requireNotNull(driver)["scenario"]?.jsonPrimitive?.content + val phase = driver["phase"]?.jsonPrimitive?.content + assumeTrue("The fixture must select a scenario and process phase", scenario != null && phase != null) + require(requireNotNull(scenario).matches(Regex("[a-z0-9-]{1,64}"))) + val url = "$FIXTURE/$scenario" + val phaseStart = events(url).size + val context = ApplicationProvider.getApplicationContext() + val directory = File(context.cacheDir, "usdt-recovery-$scenario").apply { mkdirs() } + val storeId = vssDeriveStoreId("bitkit_v1_regtest", MNEMONIC, null) + vssNewClientWithLnurlAuth(url, storeId, MNEMONIC, null, "$url/auth") + val store = UsdtRecoveryBackupStore( + put = { key, value -> vssStore(key, value) }, + keys = { vssListKeys(null).map { it.key } }, + get = { key -> vssGet(key)?.value }, + ) + val wallet = UsdtWallet( + address = usdtAddress(MNEMONIC, null), + storagePath = File(directory, if (phase == "restore") "restored.sqlite" else "original.sqlite").path, + rpcUrl = "$url/rpc", + bundlerUrl = "$url/rpc", + bridgeUrl = null, + backup = object : UsdtBackup { + override suspend fun persist(snapshot: String) { + runSuspendCatching { + store.persist(snapshot, PaykitUsdtStateBackup(emptyList(), emptyList())) + }.getOrElse { + throw UsdtException.BackupUnavailable() + } + } + }, + ) + try { + val saved = File(directory, "signed-snapshot.json") + if (phase == "prepare") { + assertTrue(wallet.history().isEmpty(), "Each scenario needs a new isolated database") + val mode = requireNotNull(request("$url/control")["mode"]).jsonPrimitive.content + request("$url/control", buildJsonObject { put("faultMode", mode) }) + request("$url/control", buildJsonObject { put("mode", "accept") }) + vssStore("WALLET", "complete-wallet-backup".encodeToByteArray()) + assertEquals("complete-wallet-backup", vssGet("WALLET")?.value?.decodeToString()) + request("$url/control", buildJsonObject { put("mode", mode) }) + val quote = wallet.quoteTransfer( + "0x1111111111111111111111111111111111111111", + 1_000_000uL, + UsdtDestination.ARBITRUM, + ) + val original = expectBackupFailure(wallet, store, url, mode) { + wallet.send(quote.id, MNEMONIC, null) + } + saved.writeText(original) + request("$url/control", buildJsonObject { put("expectedSnapshot", original) }) + request("$url/control", buildJsonObject { put("advance", 3) }) + repeat(2) { + val snapshot = expectBackupFailure(wallet, store, url, mode) { wallet.refreshTransfers() } + assertEquals(signedOperation(original), signedOperation(snapshot)) + } + assertTrue(requireNotNull(request("$url/control")["operations"]).jsonArray.isEmpty()) + assertEquals(1, wallet.history().size) + } else { + val configured = request("$url/control") + val original = configured.getValue("expectedSnapshot").jsonPrimitive.content + val mode = configured.getValue("faultMode").jsonPrimitive.content + request( + "$url/control", + buildJsonObject { + put("mode", mode) + put("advance", 3) + }, + ) + if (phase == "restore") { + assertTrue(wallet.history().isEmpty()) + store.restore( + legacyWallet = null, + legacyPaymentState = null, + restoreWallet = { wallet.restoreBackup(it) }, + restorePayments = { assertTrue(it.attempts.isEmpty() && it.receipts.isEmpty()) }, + ) + } else { + assertEquals("resume", phase) + } + assertEquals(signedOperation(original), signedOperation(wallet.exportBackup())) + val snapshot = expectBackupFailure(wallet, store, url, mode) { wallet.refreshTransfers() } + assertEquals(signedOperation(original), signedOperation(snapshot)) + assertFalse(events(url).drop(phaseStart).any { eventType(it) == "broadcast-attempt" }) + val recovery = buildJsonObject { + put("mode", "accept") + put("advance", 3) + } + request("$url/control", recovery) + wallet.refreshTransfers() + wallet.refreshTransfers() + val state = request("$url/control") + val operations = requireNotNull(state["operations"]).jsonArray + assertEquals(1, operations.size, "Retries may only resubmit the original signed operation") + assertEquals(signedOperation(original), operations.single().jsonObject["operation"]) + val allEvents = state.getValue("events").jsonArray.map { it.jsonObject } + val initialKeys = allEvents.first { eventType(it) == "backup-acknowledged" }["keys"] + val phaseEvents = allEvents.drop(phaseStart) + val firstAttempt = phaseEvents.indexOfFirst { eventType(it) == "broadcast-attempt" } + assertTrue(firstAttempt >= 0, "This process must submit the restored operation") + assertTrue( + phaseEvents.take(firstAttempt).any { + eventType(it) == "backup-acknowledged" && it["keys"] != initialKeys + }, + "This process must acknowledge its recovery record before attempting submission", + ) + assertTrue(phaseEvents.any { eventType(it) == "broadcast" }) + phaseEvents.filter { eventType(it) == "broadcast-attempt" }.forEach { + assertEquals(signedOperation(original), it["operation"]) + } + assertEquals("complete-wallet-backup", vssGet("WALLET")?.value?.decodeToString()) + assertEquals(1, wallet.history().size) + } + File(directory, "$phase-public-fixture-snapshot.json").writeText(wallet.exportBackup()) + } finally { + wallet.close() + } + } + } + + private fun events(url: String) = request("$url/control").getValue("events").jsonArray.map { it.jsonObject } + + private suspend fun expectBackupFailure( + wallet: UsdtWallet, + store: UsdtRecoveryBackupStore, + url: String, + mode: String, + operation: suspend () -> Unit, + ): String { + val before = request("$url/control") + val checkpoint = before.getValue("events").jsonArray.size + assertFailsWith { operation() } + val snapshot = wallet.exportBackup() + val after = request("$url/control") + val newEvents = after.getValue("events").jsonArray.drop(checkpoint).map { it.jsonObject } + val fault = if (mode == "reject") "backup-rejected" else "backup-ack-dropped" + assertTrue(newEvents.any { eventType(it) == fault }, "Every retry must reach the requested VSS fault") + assertFalse(newEvents.any { eventType(it) == "broadcast-attempt" }) + assertFalse(newEvents.any { eventType(it) == "backup-acknowledged" }) + if (mode == "reject") { + assertEquals(before["stores"], after["stores"]) + } else { + assertEquals("commit-drop", mode) + val records = store.snapshots() + assertTrue( + records.isNotEmpty(), + "The server committed encrypted recovery data despite losing its acknowledgement", + ) + records.forEach { assertEquals(signedOperation(snapshot), signedOperation(it.usdtWallet)) } + } + return snapshot + } + + private fun signedOperation(snapshot: String) = Json.parseToJsonElement(snapshot).jsonObject + .getValue("transfers").jsonArray.single().jsonObject.getValue("plan").jsonObject.getValue("operation") + + private fun eventType(event: JsonObject) = event["type"]?.jsonPrimitive?.content + + private fun request(address: String, body: JsonObject? = null): JsonObject { + val connection = URI(address).toURL().openConnection() as HttpURLConnection + return try { + connection.connectTimeout = 3_000 + connection.readTimeout = 3_000 + if (body != null) { + connection.requestMethod = "POST" + connection.doOutput = true + connection.setRequestProperty("Content-Type", "application/json") + connection.outputStream.use { it.write(body.toString().encodeToByteArray()) } + } + assertEquals(200, connection.responseCode) + connection.inputStream.bufferedReader().use { Json.parseToJsonElement(it.readText()).jsonObject } + } finally { + connection.disconnect() + } + } +} diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/usdt/UsdtPaymentContentTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/usdt/UsdtPaymentContentTest.kt index f0b44916a6..91276b7782 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/usdt/UsdtPaymentContentTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/usdt/UsdtPaymentContentTest.kt @@ -14,8 +14,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -36,6 +39,7 @@ import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test +import to.bitkit.R import to.bitkit.repositories.UsdtWalletState import to.bitkit.test.annotations.ComposeUi import to.bitkit.ui.scaffold.AppTopBar @@ -309,6 +313,73 @@ class UsdtPaymentContentTest { composeTestRule.onNodeWithTag("UsdtSendSuccess").assertIsDisplayed() } + @Test + fun deadlineExpiryRetainsItsTitleAndBodyWhenRecoveryFailsTheSamePayment() { + val payment = transfer(quote, UsdtTransferStatus.PENDING) + val wallet = mutableStateOf(UsdtWalletState(transfers = persistentListOf(payment))) + val state = UsdtSendState( + quote = quote, + submitted = true, + error = R.string.wallet__payment_request_expired, + submissionErrorStatus = UsdtTransferStatus.PENDING, + submissionDeadlineExpired = true, + ) + composeTestRule.setContent { Content(state, UsdtPage.AMOUNT, wallet.value) } + composeTestRule.onAllNodesWithText("The payment request has expired.").assertCountEquals(2) + composeTestRule.runOnIdle { + wallet.value = wallet.value.copy(transfers = persistentListOf(payment.copy(status = UsdtTransferStatus.FAILED))) + } + composeTestRule.onAllNodesWithText("The payment request has expired.").assertCountEquals(2) + composeTestRule.onNodeWithText("Transaction Failed").assertDoesNotExist() + composeTestRule.onNodeWithTag("UsdtConfirm").assertDoesNotExist() + composeTestRule.runOnIdle { + wallet.value = wallet.value.copy(transfers = persistentListOf(payment.copy(status = UsdtTransferStatus.REPLACED))) + } + composeTestRule.onAllNodesWithText("The payment request has expired.").assertCountEquals(0) + composeTestRule.onNodeWithText("Transaction Failed").assertIsDisplayed() + composeTestRule.runOnIdle { + wallet.value = wallet.value.copy(transfers = persistentListOf(payment.copy(status = UsdtTransferStatus.CONFIRMED))) + } + composeTestRule.onAllNodesWithText("The payment request has expired.").assertCountEquals(0) + } + + @Test + fun backupRetryKeepsTheSubmittedPaymentAndNeverReopensConfirmation() { + val payment = transfer(quote, UsdtTransferStatus.PENDING) + val state = mutableStateOf( + UsdtSendState( + quote = quote, + submitted = true, + canRetryRecovery = true, + error = R.string.common__error_body, + submissionErrorStatus = UsdtTransferStatus.PENDING, + ) + ) + var retries = 0 + var confirmations = 0 + composeTestRule.setContent { + Content( + state.value, + UsdtPage.AMOUNT, + UsdtWalletState(transfers = persistentListOf(payment)), + amountEditable = false, + onRetryRecovery = { + retries += 1 + state.value = state.value.copy(retryingRecovery = true) + }, + onConfirm = { confirmations += 1 }, + ) + } + composeTestRule.onNodeWithTag("UsdtRetryRecovery").performClick() + composeTestRule.onNodeWithText("Retry").assertIsNotEnabled() + composeTestRule.onNodeWithTag("UsdtConfirm").assertDoesNotExist() + composeTestRule.runOnIdle { + assertEquals(1, retries) + assertEquals(0, confirmations) + assertEquals(quote.id, state.value.quote?.id) + } + } + private fun assertCompletionTransition(destination: UsdtDestination, pendingStatus: UsdtTransferStatus) { val payment = quote.copy(destination = destination) val transfer = transfer(payment, pendingStatus) @@ -350,6 +421,8 @@ class UsdtPaymentContentTest { amountEditable: Boolean = true, onPageChange: (UsdtPage) -> Unit = {}, onBack: () -> Unit = {}, + onRetryRecovery: (() -> Unit)? = null, + onConfirm: () -> Unit = {}, onDetails: () -> Unit = {}, ) { AppThemeSurface { @@ -368,9 +441,10 @@ class UsdtPaymentContentTest { onPageChange = onPageChange, onBack = onBack, onReview = {}, - onConfirm = {}, + onConfirm = onConfirm, onDone = {}, onDetails = onDetails, + onRetryRecovery = onRetryRecovery, amountEditable = amountEditable, ) } diff --git a/app/src/main/java/to/bitkit/data/backup/UsdtRecoveryBackupStore.kt b/app/src/main/java/to/bitkit/data/backup/UsdtRecoveryBackupStore.kt new file mode 100644 index 0000000000..335429403a --- /dev/null +++ b/app/src/main/java/to/bitkit/data/backup/UsdtRecoveryBackupStore.kt @@ -0,0 +1,79 @@ +package to.bitkit.data.backup + +import com.synonym.bitkitcore.UsdtException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.serialization.Serializable +import to.bitkit.di.json +import to.bitkit.models.PaykitUsdtStateBackup +import java.security.MessageDigest +import java.util.Locale + +@Serializable +data class UsdtRecoveryBackupV1( + val version: Int, + val usdtWallet: String, + val paykitUsdtState: PaykitUsdtStateBackup, +) + +/** + * Immutable encrypted VSS records preserve concurrent writers' signed operations. Retrying a lost + * acknowledgement writes the same content-addressed record. Pruning needs a compaction protocol that + * proves every writer's recovery data remains acknowledged; an unconditional VSS replacement cannot. + */ +class UsdtRecoveryBackupStore( + private val put: suspend (String, ByteArray) -> Unit, + private val keys: suspend () -> List, + private val get: suspend (String) -> ByteArray?, +) { + constructor(client: VssBackupClient) : this( + put = { key, data -> + client.setup().getOrThrow() + client.putObject(key, data).getOrThrow() + }, + keys = { + client.setup().getOrThrow() + client.listKeys().getOrThrow().map { it.key } + }, + get = { key -> client.getObject(key).getOrThrow()?.value }, + ) + + suspend fun persist(snapshot: String, paymentState: PaykitUsdtStateBackup) { + val data = json.encodeToString(UsdtRecoveryBackupV1(1, snapshot, paymentState)).encodeToByteArray() + put(recoveryKey(data), data) + } + + suspend fun snapshots(): List = keys().filter { it.startsWith(PREFIX) }.sorted().map { key -> + currentCoroutineContext().ensureActive() + if (!key.startsWith(VERSION_PREFIX)) throw UsdtException.InvalidBackup() + val data = get(key) ?: throw UsdtException.InvalidBackup() + if (recoveryKey(data) != key) throw UsdtException.InvalidBackup() + json.decodeFromString(data.decodeToString()).also { + if (it.version != 1) throw UsdtException.InvalidBackup() + } + } + + suspend fun restore( + legacyWallet: String?, + legacyPaymentState: PaykitUsdtStateBackup?, + restoreWallet: suspend (String) -> Unit, + restorePayments: suspend (PaykitUsdtStateBackup) -> Unit, + ) { + val records = snapshots() + legacyWallet?.let { restoreWallet(it) } + legacyPaymentState?.let { restorePayments(it) } + for (record in records) { + restoreWallet(record.usdtWallet) + restorePayments(record.paykitUsdtState) + } + } + + private fun recoveryKey(data: ByteArray) = VERSION_PREFIX + MessageDigest.getInstance("SHA-256").digest(data) + .joinToString("") { "%02x".format(Locale.ROOT, it) } + + companion object { + /** Filter decrypted names locally: VSS obfuscates whole keys, not their path segments. */ + const val PREFIX = "USDT_RECOVERY_" + private const val VERSION_PREFIX = "${PREFIX}V1_" + } +} diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index 8ac2cd39a2..65fffd7aa9 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -242,6 +242,7 @@ class Keychain @Inject constructor( PAYKIT_KEY_GENERATION, PAYKIT_RECOVERY_BACKUP, PAYKIT_PENDING_BACKUP_RESTORE, + USDT_PENDING_BACKUP_RESTORE, PAYKIT_PENDING_PAYMENT_PROOFS, ONCHAIN_SEND_ATTEMPT, PAYKIT_ACCEPTED_PAYMENT_REQUESTS, diff --git a/app/src/main/java/to/bitkit/env/BackupTestEndpoints.kt b/app/src/main/java/to/bitkit/env/BackupTestEndpoints.kt new file mode 100644 index 0000000000..fb924cea22 --- /dev/null +++ b/app/src/main/java/to/bitkit/env/BackupTestEndpoints.kt @@ -0,0 +1,24 @@ +package to.bitkit.env + +import java.net.URI + +internal data class BackupTestEndpoints(val vss: String, val auth: String) { + companion object { + fun resolve(enabled: Boolean, vss: String?, auth: String?): BackupTestEndpoints? { + if (!enabled) return null + val vssUrl = vss.orEmpty().trim() + val authUrl = auth.orEmpty().trim() + if (vssUrl.isEmpty() && authUrl.isEmpty()) return null + require(valid(vssUrl) && valid(authUrl)) { + "E2E backup endpoints must be a valid pair of localhost URLs with explicit ports" + } + return BackupTestEndpoints(vssUrl, authUrl) + } + + private fun valid(value: String): Boolean = runCatching { + val url = URI(value) + url.scheme in listOf("http", "https") && url.host == "localhost" && url.port in 1..65535 && + url.userInfo == null && url.query == null && url.fragment == null && !url.path.isNullOrEmpty() + }.getOrDefault(false) + } +} diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 09797720d0..e86f7c4a9a 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -110,14 +110,20 @@ internal object Env { val vssStoreIdPrefix get() = "bitkit_v1_${network.name.lowercase()}" + private val backupTestEndpoints get() = BackupTestEndpoints.resolve( + enabled = isDebug && isE2eTest, + vss = BuildConfig.E2E_VSS_SERVER_URL, + auth = BuildConfig.E2E_LNURL_AUTH_SERVER_URL, + ) + val vssServerUrl - get() = when (network) { + get() = backupTestEndpoints?.vss ?: when (network) { Network.BITCOIN -> "https://bitkit.to/vss_rs_auth" else -> "https://bitkit.stag0.blocktank.to/vss_rs_auth" } val lnurlAuthServerUrl - get() = when (network) { + get() = backupTestEndpoints?.auth ?: when (network) { Network.BITCOIN -> "https://bitkit.to/lnurl_auth/auth" else -> "https://bitkit.stag0.blocktank.to/lnurl_auth/auth" } diff --git a/app/src/main/java/to/bitkit/models/BackupCategory.kt b/app/src/main/java/to/bitkit/models/BackupCategory.kt index 0181b711e7..d71ad17e1d 100644 --- a/app/src/main/java/to/bitkit/models/BackupCategory.kt +++ b/app/src/main/java/to/bitkit/models/BackupCategory.kt @@ -26,6 +26,10 @@ enum class BackupCategory( icon = R.drawable.ic_timer_alt, title = R.string.settings__backup__category_wallet, ), + USDT_RECOVERY( + icon = R.drawable.ic_timer_alt, + title = R.string.usdt__settings_title, + ), SETTINGS( icon = R.drawable.ic_settings, title = R.string.settings__backup__category_settings, diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index cf99346ba0..55e88e56c1 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -2,6 +2,7 @@ package to.bitkit.repositories import android.content.Context import com.synonym.bitkitcore.UsdtException +import com.synonym.vssclient.VssException import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.FlowPreview @@ -40,6 +41,7 @@ import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsStore import to.bitkit.data.WatchOnlyAccountStore import to.bitkit.data.WidgetsStore +import to.bitkit.data.backup.UsdtRecoveryBackupStore import to.bitkit.data.backup.VssBackupClient import to.bitkit.data.backup.VssBackupClientLdk import to.bitkit.data.backup.VssStoreIdProvider @@ -135,6 +137,8 @@ class BackupRepo @Inject constructor( private val db: AppDb, ) { private val walletBackupMutex = Mutex() + private val usdtBackupMutex = Mutex() + private val usdtRecoveryBackup = UsdtRecoveryBackupStore(vssBackupClient) private val scope = appScope(ioDispatcher, TAG) private val backupJobs = mutableMapOf() @@ -182,16 +186,24 @@ class BackupRepo @Inject constructor( } suspend fun hasPendingWalletRestore(): Boolean = withContext(ioDispatcher) { - keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name) + keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name) || isUsdtRestorePending() } private fun currentTimeMillis(): Long = nowMillis(clock) private fun isRestorePending(): Boolean = currentTimeMillis() < restorePendingUntil.value - private fun shouldSkipBackup(): Boolean = _isRestoring.value || _isWiping.value || isRestorePending() + private fun isUsdtRestorePending(): Boolean = keychain.exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name) + private fun shouldSkipBackup( + category: BackupCategory? = null, + ): Boolean = if (category == BackupCategory.USDT_RECOVERY) { + _isWiping.value || isUsdtRestorePending() + } else { + _isRestoring.value || _isWiping.value || isRestorePending() || + ((category == null || category == BackupCategory.WALLET) && isUsdtRestorePending()) + } private fun BackupItemStatus.shouldBackup(category: BackupCategory) = this.isRequired && !this.running && - !shouldSkipBackup() && + !shouldSkipBackup(category) && failedBackupRequired[category] != this.required fun startObservingBackups() { @@ -382,6 +394,15 @@ class BackupRepo @Inject constructor( dataListenerJobs.add( observeBackupChanges(paykitUsdtPaymentRepo.get().backupStateVersion, BackupCategory.WALLET) ) + dataListenerJobs.add( + observeBackupChanges(paykitUsdtPaymentRepo.get().backupStateVersion, BackupCategory.USDT_RECOVERY) + ) + dataListenerJobs.add( + observeBackupChanges( + usdtRepo.get().state.map { it.transfers }.distinctUntilChanged(), + BackupCategory.USDT_RECOVERY, + ) + ) dataListenerJobs.add( observeBackupChanges( usdtRepo.get().state.map { it.transfers }.distinctUntilChanged(), @@ -428,7 +449,7 @@ class BackupRepo @Inject constructor( private fun observeBackupChanges(flow: Flow<*>, category: BackupCategory): Job = scope.launch { flow.drop(1).collect { - if (shouldSkipBackup()) return@collect + if (shouldSkipBackup(category)) return@collect markBackupRequired(category) } } @@ -467,7 +488,7 @@ class BackupRepo @Inject constructor( if (category == BackupCategory.WALLET) paykitSdkService.isPaymentSubmissionActive.first { !it } val status = cacheStore.backupStatuses.first()[category] ?: BackupItemStatus() - if (status.isRequired && !shouldSkipBackup()) { + if (status.isRequired && !shouldSkipBackup(category)) { triggerBackup(category) } else { Logger.debug("Backup no longer needed for: '$category'", context = TAG) @@ -541,10 +562,10 @@ class BackupRepo @Inject constructor( it.copy(running = true, required = backupRequired) } - val upload = if (category == BackupCategory.WALLET) { - uploadWalletBackup(null) - } else { - runSuspendCatching { + val upload = when (category) { + BackupCategory.WALLET -> uploadWalletBackup() + BackupCategory.USDT_RECOVERY -> uploadUsdtRecoveryBackup() + else -> runSuspendCatching { vssBackupClient.putObject(key = category.name, data = getBackupDataBytes(category)).getOrThrow() } } @@ -572,15 +593,47 @@ class BackupRepo @Inject constructor( } } - suspend fun persistWalletBackup(usdt: String? = null): Result = - uploadWalletBackup(usdt).onFailure { markBackupRequired(BackupCategory.WALLET) } + suspend fun persistWalletBackup(): Result = + uploadWalletBackup().onFailure { markBackupRequired(BackupCategory.WALLET) } + + suspend fun persistUsdtRecoveryBackup(snapshot: String): Result = runSuspendCatching { + cacheStore.updateBackupStatus(BackupCategory.USDT_RECOVERY) { + it.copy(required = maxOf(currentTimeMillis(), it.required + 1, it.synced + 1)) + } + uploadUsdtRecoveryBackup(snapshot).getOrThrow() + }.onFailure { markBackupRequired(BackupCategory.USDT_RECOVERY) } - private suspend fun uploadWalletBackup(usdt: String?): Result = withContext(ioDispatcher) { + private suspend fun uploadUsdtRecoveryBackup(snapshot: String? = null): Result = withContext(ioDispatcher) { + runSuspendCatching { + usdtBackupMutex.withLock { + if (shouldSkipBackup(BackupCategory.USDT_RECOVERY)) throw UsdtException.BackupUnavailable() + val payments = paykitUsdtPaymentRepo.get() + repeat(WALLET_SNAPSHOT_ATTEMPTS) { + val version = payments.backupStateVersion.value + val transfers = usdtRepo.get().state.value.transfers + val core = snapshot ?: usdtRepo.get().backupSnapshot() ?: return@withLock + val associations = payments.backupSnapshot() + if (version == payments.backupStateVersion.value && + (snapshot != null || transfers == usdtRepo.get().state.value.transfers) + ) { + if (shouldSkipBackup(BackupCategory.USDT_RECOVERY)) throw UsdtException.BackupUnavailable() + usdtRecoveryBackup.persist(core, associations) + return@withLock + } + yield() + } + throw WalletBackupSnapshotChanged() + } + } + } + + private suspend fun uploadWalletBackup(): Result = withContext(ioDispatcher) { runSuspendCatching { walletBackupMutex.withLock { if (shouldSkipBackup()) throw UsdtException.BackupUnavailable() vssBackupClient.setup().getOrThrow() - val data = getWalletBackupDataBytes(usdt) + val data = getWalletBackupDataBytes() + if (shouldSkipBackup()) throw UsdtException.BackupUnavailable() vssBackupClient.putObject(key = BackupCategory.WALLET.name, data = data).getOrThrow() } }.map { } @@ -601,7 +654,7 @@ class BackupRepo @Inject constructor( when { e is WalletBackupSnapshotChanged -> Logger.debug("Deferred changing backup snapshot for: '$category'", context = TAG) - e.isPaykitTemporarilyUnavailable() -> retryAfterTemporaryFailure(category, backupRequired, e) + e.isTemporaryBackupFailure() -> retryAfterTemporaryFailure(category, backupRequired, e) else -> Logger.error("Backup failed for: '$category'", e, context = TAG) } } @@ -672,6 +725,7 @@ class BackupRepo @Inject constructor( } BackupCategory.LIGHTNING_CONNECTIONS -> throw NotImplementedError("LIGHTNING backup is managed by ldk-node") + BackupCategory.USDT_RECOVERY -> throw UsdtException.BackupUnavailable() } private suspend fun getMetadataBackupDataBytes(): ByteArray = withContext(ioDispatcher) { @@ -703,14 +757,14 @@ class BackupRepo @Inject constructor( json.encodeToString(payload).toByteArray() } - private suspend fun getWalletBackupDataBytes(usdt: String? = null): ByteArray { + private suspend fun getWalletBackupDataBytes(): 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) } + val snapshot = runSuspendCatching { captureWalletBackupDataBytes(sdkGeneration) } attemptsRemaining-- if (snapshot.exceptionOrNull() !is WalletBackupSnapshotChanged || attemptsRemaining == 0) { return snapshot.getOrThrow() @@ -719,8 +773,8 @@ class BackupRepo @Inject constructor( } } - private suspend fun captureWalletBackupDataBytes(usdt: String?, sdkGeneration: Long): ByteArray { - check(!keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)) { + private suspend fun captureWalletBackupDataBytes(sdkGeneration: Long): ByteArray { + check(!keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name) && !isUsdtRestorePending()) { "Wallet backup restore is incomplete" } lateinit var transfers: List @@ -763,7 +817,7 @@ class BackupRepo @Inject constructor( paykitSdkBackupState = paykitSdkBackupState.state, watchOnlyAccounts = watchOnlyAccountSnapshot.accounts, watchOnlyAccountAllocationState = watchOnlyAccountSnapshot.allocationState, - usdtWallet = usdt ?: usdtRepo.get().backupSnapshot(), + usdtWallet = usdtRepo.get().backupSnapshot(), paykitPaymentState = PaykitPaymentStateBackup( subscriptions = paykitPresentationStore.backupSnapshot(), pendingProofs = snapshotProofs, @@ -788,6 +842,9 @@ class BackupRepo @Inject constructor( suspend fun performFullRestoreFromLatestBackup( onCacheRestored: suspend () -> Unit = {}, ): Result = withContext(ioDispatcher) { + if (isUsdtRestorePending() && !keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)) { + return@withContext restoreUsdtRecoveryAfterLegacyMigration() + } Logger.debug("Full restore starting", context = TAG) _isRestoring.update { true } @@ -801,6 +858,7 @@ class BackupRepo @Inject constructor( if (!keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)) { keychain.upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, "") } + restoreUsdtRecovery() performRestore(BackupCategory.METADATA) { dataBytes -> val restored = restoreMetadataBackup(dataBytes, onCacheRestored) if (restored.needsRewrite) categoriesNeedingRewrite += BackupCategory.METADATA @@ -861,6 +919,42 @@ class BackupRepo @Inject constructor( return@withContext result } + suspend fun restoreUsdtRecoveryAfterLegacyMigration(): Result = withContext(ioDispatcher) { + runSuspendCatching { restoreUsdtRecovery(retainWallet = false) } + } + + private suspend fun restoreUsdtRecovery(retainWallet: Boolean = true) { + val recoveryKey = Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name + if (!isUsdtRestorePending()) keychain.upsertString(recoveryKey, "") + paykitUsdtPaymentRepo.get().pauseForRecoveryRestore() + usdtRepo.get().waitForRecoveryIdle() + usdtBackupMutex.withLock { } + walletBackupMutex.withLock { } + vssBackupClient.setup().getOrThrow() + val retained = keychain.loadString(recoveryKey)?.takeIf { it.isNotEmpty() } + ?: keychain.loadString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name) + val legacyData = retained?.takeIf { it.isNotEmpty() }?.encodeToByteArray() + ?: vssBackupClient.getObject(BackupCategory.WALLET.name).getOrThrow()?.value + if (legacyData != null) { + keychain.upsertString(recoveryKey, legacyData.decodeToString()) + if (retainWallet) { + keychain.upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, legacyData.decodeToString()) + } + } + val legacy = legacyData?.let { json.decodeFromString(it.decodeToString()) } + usdtRecoveryBackup.restore( + legacyWallet = legacy?.usdtWallet, + legacyPaymentState = legacy?.paykitPaymentState?.usdt, + restoreWallet = { usdtRepo.get().restoreBackup(it).getOrThrow() }, + restorePayments = { paykitUsdtPaymentRepo.get().restoreBackup(it) }, + ) + keychain.delete(recoveryKey) + val restoredAt = currentTimeMillis() + cacheStore.updateBackupStatus(BackupCategory.USDT_RECOVERY) { + it.copy(running = false, synced = restoredAt, required = restoredAt) + } + } + private suspend fun restoreMetadataBackup( dataBytes: ByteArray, onCacheRestored: suspend () -> Unit, @@ -916,7 +1010,6 @@ class BackupRepo @Inject constructor( keychain.upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, String(dataBytes)) val parsed = json.decodeFromString(String(dataBytes)) var restoredAttempt: OnchainSendAttempt? = null - parsed.usdtWallet?.let { usdtRepo.get().restoreBackup(it).getOrThrow() } parsed.paykitPaymentState?.let { // Validate every proof before writing a guard or replacing existing payment state. it.pendingProofs.forEach { proof -> proof.restored() } @@ -941,7 +1034,6 @@ class BackupRepo @Inject constructor( paykitPaymentRequestRepo.get().clear() paykitPresentationStore.restoreBackup(it.subscriptions) paykitPaymentProofRepo.get().restoreBackup(it.pendingProofs) - it.usdt?.let { usdt -> paykitUsdtPaymentRepo.get().restoreBackup(usdt) } paykitPresentationStore.restoreAcceptedOneTimeRequests(it.acceptedOneTimeRequests.orEmpty()) } db.transferDao().upsert(parsed.transfers) @@ -1008,7 +1100,7 @@ class BackupRepo @Inject constructor( vssBackupClient.setup().getOrThrow() coroutineScope { BackupCategory.entries - .filter { it != BackupCategory.LIGHTNING_CONNECTIONS } + .filter { it != BackupCategory.LIGHTNING_CONNECTIONS && it != BackupCategory.USDT_RECOVERY } .map { category -> async { getRemoteBackupTimestamp(category) } } .mapNotNull { it.await() } .filter { it > 0uL } @@ -1136,7 +1228,7 @@ class BackupRepo @Inject constructor( private const val SYNC_STATUS_DEBOUNCE = 500L // 500ms debounce for sync status updates private val VSS_TIMESTAMP_TIMEOUT = 60.seconds - /** First wait before retrying a backup that failed on a temporary Paykit error, doubled per retry. */ + /** First wait before retrying a backup that failed on a temporary storage error, doubled per retry. */ private val TEMPORARY_FAILURE_RETRY_DELAY = 10.seconds /** Longest wait between retries, past the Paykit shared-state lock's write cooldown. */ @@ -1178,3 +1270,12 @@ private data class RestoredCoreBackup( val createdAt: Long, val needsRewrite: Boolean, ) + +internal fun Throwable.isTemporaryBackupFailure(): Boolean = isPaykitTemporarilyUnavailable() || when (this) { + is VssException.ConnectionException, + is VssException.NetworkException, + is VssException.StoreException, + is VssException.PutException, + -> true + else -> false +} diff --git a/app/src/main/java/to/bitkit/repositories/PaykitUsdtPaymentRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitUsdtPaymentRepo.kt index 1c04001e3d..2d9e01aaea 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitUsdtPaymentRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitUsdtPaymentRepo.kt @@ -220,21 +220,32 @@ class PaykitUsdtPaymentRepo @Inject constructor( for (restored in backup.attempts) { val key = restored.wallet to restored.quoteId val local = attempts[key] + if (local != null && local.bindingState() != restored.bindingState()) { + throw UsdtException.InvalidBackup() + } attempts[key] = local?.copy( - paymentStarted = local.paymentStarted || restored.paymentStarted, - proof = local.proof ?: restored.proof, + paymentStarted = local.paymentStarted || restored.paymentStarted || + restored.proof != null || restored.proofQueued, ) ?: restored } save( State( - attempts = attempts.values.map { it.copy(proofQueued = false) }, + attempts = attempts.values.map { + it.copy( + paymentStarted = it.paymentStarted || it.proof != null || it.proofQueued, + proof = null, + proofQueued = false, + ) + }, receipts = (state.receipts + backup.receipts.map { it.restored() }) - .distinctBy { it.wallet to it.paymentId }, + .distinctBy { it.wallet to it.paymentId }.map { it.copy(verified = false) }, ) ) } } + suspend fun pauseForRecoveryRestore() = mutex.withLock { stateGeneration++ } + suspend fun resetForWipe() = withContext(ioDispatcher) { mutex.withLock { stateGeneration++ @@ -510,7 +521,8 @@ class PaykitUsdtPaymentRepo @Inject constructor( status !in setOf(UsdtTransferStatus.FAILED, UsdtTransferStatus.REPLACED) } - suspend fun reconcile() = withContext(ioDispatcher) { + suspend fun reconcile(): Result = withContext(ioDispatcher) { + if (usdt.isRecoveryRestorePending) return@withContext Result.success(Unit) runSuspendCatching { reconcileMutex.withLock { val previousProtection = Triple(activeWallet, activeIdentity, _resumableRequests.value) diff --git a/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt b/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt index 779784dc67..b92f0983bf 100644 --- a/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt @@ -55,6 +55,7 @@ class UsdtRepo @Inject constructor( private var rateLimitedUntil = 0L private val _state = MutableStateFlow(UsdtWalletState()) val state = _state.asStateFlow() + val recoveryGeneration: Long get() = service.recoveryGeneration fun consumeReceipt(id: String): Boolean = _state.getAndUpdate { it.copy(pendingReceipts = it.pendingReceipts.filterNot { receipt -> receipt.id == id }.toImmutableList()) }.pendingReceipts.any { it.id == id } @@ -150,6 +151,19 @@ class UsdtRepo @Inject constructor( fun backupSnapshot(): String? = service.backupSnapshot() + val isRecoveryRestorePending: Boolean get() = service.isRecoveryRestorePending + + suspend fun waitForRecoveryIdle() = mutex.withLock { } + + suspend fun retryTransfer(id: String): Result = paymentOperation { + val wallet = wallet() + val transfer = wallet.history().firstOrNull { it.id == id } ?: throw UsdtException.InvalidBackup() + if (transfer.status == UsdtTransferStatus.PENDING) { + val transfers = wallet.refreshTransfers().toImmutableList() + _state.update { it.copy(transfers = transfers) } + } + } + suspend fun restoreBackup(snapshot: String) = withContext(ioDispatcher) { mutex.withLock { runSuspendCatching { diff --git a/app/src/main/java/to/bitkit/services/UsdtService.kt b/app/src/main/java/to/bitkit/services/UsdtService.kt index 518b3de992..ef299b0297 100644 --- a/app/src/main/java/to/bitkit/services/UsdtService.kt +++ b/app/src/main/java/to/bitkit/services/UsdtService.kt @@ -12,11 +12,13 @@ import com.synonym.bitkitcore.usdtParseAmount import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env import to.bitkit.repositories.BackupRepo +import to.bitkit.utils.Logger import java.io.File import java.io.IOException import java.security.MessageDigest import java.text.DecimalFormatSymbols import java.util.Locale +import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton @@ -31,6 +33,24 @@ class UsdtService @Inject constructor( private var credentialFingerprint: ByteArray? = null private var addressFingerprint: ByteArray? = null private var cachedPaymentAddress: String? = null + private val backupAcknowledgements = AtomicLong() + val recoveryGeneration: Long get() = backupAcknowledgements.get() + + internal val recoveryBackup = object : UsdtBackup { + override suspend fun persist(snapshot: String) { + backupRepo.get().persistUsdtRecoveryBackup(snapshot).getOrElse { + Logger.error( + "Failed to persist USDT recovery backup with '${it.javaClass.simpleName}'", + context = TAG, + ) + throw UsdtException.BackupUnavailable() + } + backupAcknowledgements.incrementAndGet() + } + } + + val isRecoveryRestorePending: Boolean + get() = keychain.exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name) @get:Synchronized val paymentAddress: String @@ -51,6 +71,7 @@ class UsdtService @Inject constructor( @Synchronized fun wallet(requiresNetwork: Boolean = true): UsdtWallet { + if (requiresNetwork && isRecoveryRestorePending) throw UsdtException.BackupUnavailable() if (requiresNetwork && !Env.isUsdtEnabled) throw UsdtException.NotConfigured() val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw UsdtException.InvalidCredentials() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) @@ -69,11 +90,7 @@ class UsdtService @Inject constructor( rpcUrl = Env.usdtRpcUrl ?: "http://127.0.0.1:1", bundlerUrl = Env.usdtBundlerUrl.ifEmpty { "http://127.0.0.1:1" }, bridgeUrl = Env.usdtBridgesUrl, - backup = object : UsdtBackup { - override suspend fun persist(snapshot: String) { - backupRepo.get().persistWalletBackup(snapshot).getOrElse { throw UsdtException.BackupUnavailable() } - } - }, + backup = recoveryBackup, ).also { wallet = it credentialFingerprint = fingerprint @@ -150,4 +167,8 @@ class UsdtService @Inject constructor( keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name), submissionDeadline?.toString(), ) + + companion object { + private const val TAG = "UsdtService" + } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt index e5b1a641b9..5edd4a9a8b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt @@ -144,6 +144,7 @@ fun PaykitUsdtReview( }, onDone = onDone, onDetails = { details = true }, + onRetryRecovery = { viewModel.retryRecovery() }, hideBalance = settings.hideBalance, amountEditable = false, isPaymentRequest = uiState.isPaymentRequest, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtSendContent.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtSendContent.kt index 3db1796909..207c9a6ab1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtSendContent.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtSendContent.kt @@ -117,6 +117,7 @@ internal fun UsdtPaymentContent( onConfirm: () -> Unit, onDone: () -> Unit, onDetails: () -> Unit = {}, + onRetryRecovery: (() -> Unit)? = null, onMaximum: (() -> Unit)? = null, hideBalance: Boolean = false, amountEditable: Boolean = true, @@ -178,11 +179,14 @@ internal fun UsdtPaymentContent( val bridgeNeedsAttention = status == UsdtTransferStatus.BRIDGE_NEEDS_ATTENTION || status == UsdtTransferStatus.BRIDGE_FAILED val failed = status == UsdtTransferStatus.FAILED || status == UsdtTransferStatus.REPLACED + val submissionError = state.submissionError(status) val title = when { showSubmitted && status == UsdtTransferStatus.BRIDGE_REFUNDED -> R.string.usdt__deposit_refunded showSubmitted && bridgeNeedsAttention -> R.string.usdt__bridge_attention + showSubmitted && submissionError == R.string.wallet__payment_request_expired -> + R.string.wallet__payment_request_expired showSubmitted && failed -> R.string.wallet__send_error_tx_failed - showSubmitted && state.error != null -> R.string.wallet__payment_request_expired + showSubmitted && submissionError != null -> R.string.common__error showSubmitted -> R.string.usdt__submitted quote != null && isPaymentRequest -> R.string.wallet__payment_request quote != null -> R.string.wallet__send_review @@ -200,9 +204,13 @@ internal fun UsdtPaymentContent( failed, bridgeNeedsAttention, status == UsdtTransferStatus.BRIDGE_REFUNDED, - state.error, + submissionError, onDetails, - onDone + onDone, + onRetryRecovery = onRetryRecovery.takeIf { + state.canRetryRecovery && submissionError != null && status == UsdtTransferStatus.PENDING + }, + retryingRecovery = state.retryingRecovery, ) quote != null -> UsdtConfirmation( quote, @@ -258,6 +266,8 @@ private fun ColumnScope.UsdtSubmittedContent( error: Int?, onDetails: () -> Unit, onDone: () -> Unit, + onRetryRecovery: (() -> Unit)? = null, + retryingRecovery: Boolean = false, ) { quote?.let { UsdtAmountHeader(usdtFormatAmount(it.amount), displayAmount = usdtOverviewAmount(it.amount)) } VerticalSpacer(32.dp) @@ -280,6 +290,15 @@ private fun ColumnScope.UsdtSubmittedContent( ) } FillHeight() + if (onRetryRecovery != null) { + SecondaryButton( + text = stringResource(R.string.common__retry), + onClick = onRetryRecovery, + enabled = !retryingRecovery, + modifier = Modifier.fillMaxWidth().testTag("UsdtRetryRecovery") + ) + VerticalSpacer(16.dp) + } Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { SecondaryButton( text = stringResource(R.string.wallet__send_details), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt index e11b4e185a..085326d961 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt @@ -8,6 +8,7 @@ import com.synonym.bitkitcore.UsdtDestination import com.synonym.bitkitcore.UsdtException import com.synonym.bitkitcore.UsdtPaymentRequest import com.synonym.bitkitcore.UsdtQuote +import com.synonym.bitkitcore.UsdtTransferStatus import com.synonym.bitkitcore.usdtFormatAmount import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList @@ -77,6 +78,7 @@ class UsdtViewModel @Inject constructor( private val _state = MutableStateFlow(UsdtSendState()) private var maximumQuote: UsdtQuote? = null private var preparedQuote: PreparedQuote? = null + private var submissionFailureGeneration: Long? = null val state = _state.asStateFlow() var isSendPresented: Boolean @@ -90,7 +92,17 @@ class UsdtViewModel @Inject constructor( suspend fun refresh(history: Boolean = false) { repo.refresh(history) - .onSuccess { _state.update { it.copy(refreshError = null) } } + .onSuccess { + _state.update { current -> + val recovered = current.submitted && current.canRetryRecovery && + repo.recoveryGeneration != submissionFailureGeneration + current.copy( + refreshError = null, + error = current.error.takeUnless { recovered }, + canRetryRecovery = current.canRetryRecovery && !recovered, + ) + } + } .onFailure { error -> Logger.warn("Failed to refresh USDT with '${error.javaClass.simpleName}'", context = TAG) _state.update { it.copy(refreshError = error.messageResource()) } @@ -246,6 +258,31 @@ class UsdtViewModel @Inject constructor( fun cancelAuthentication() = _state.update { it.copy(authenticationRequired = false) } fun edit() = _state.update { UsdtSendState(refreshError = it.refreshError) } + fun retryRecovery() = viewModelScope.launch { + val current = state.value + val quote = current.quote ?: return@launch + if (!current.submitted || !current.canRetryRecovery || current.retryingRecovery) return@launch + _state.update { it.copy(retryingRecovery = true) } + try { + val result = repo.retryTransfer(quote.id) + if (!_state.value.submitted || _state.value.quote?.id != quote.id) return@launch + result + .onSuccess { + if (repo.recoveryGeneration != submissionFailureGeneration) { + _state.update { it.copy(error = null, canRetryRecovery = false) } + } + } + .onFailure { error -> + submissionFailureGeneration = repo.recoveryGeneration + _state.update { it.copy(error = error.messageResource()) } + } + } finally { + _state.update { + if (it.submitted && it.quote?.id == quote.id) it.copy(retryingRecovery = false) else it + } + } + } + private data class PreparedQuote(val quote: UsdtQuote, val amount: String, val sender: String) private fun send(quote: UsdtQuote) = viewModelScope.launch { @@ -264,15 +301,18 @@ class UsdtViewModel @Inject constructor( } .onFailure { error -> Logger.warn("Failed to send USDT with '${error.javaClass.simpleName}'", context = TAG) - val recorded = wallet.value.transfers.any { it.id == quote.id } + val transfer = wallet.value.transfers.firstOrNull { it.id == quote.id } + val recorded = transfer != null + submissionFailureGeneration = repo.recoveryGeneration _state.update { it.copy( submitted = recorded, - busy = recorded, + busy = false, quote = quote.takeIf { recorded }, - error = error.messageResource().takeIf { - !recorded || error is UsdtException.SubmissionDeadlineExpired - } + error = error.messageResource(), + submissionErrorStatus = transfer?.status, + submissionDeadlineExpired = error is UsdtException.SubmissionDeadlineExpired, + canRetryRecovery = recorded && error is UsdtException.BackupUnavailable, ) } } @@ -285,6 +325,10 @@ data class UsdtSendState( val quote: UsdtQuote? = null, val busy: Boolean = false, val submitted: Boolean = false, + val retryingRecovery: Boolean = false, + val canRetryRecovery: Boolean = false, + val submissionErrorStatus: UsdtTransferStatus? = null, + val submissionDeadlineExpired: Boolean = false, val authenticationRequired: Boolean = false, val warning: SanityWarning? = null, val confirmedWarnings: ImmutableList = persistentListOf(), @@ -294,6 +338,13 @@ data class UsdtSendState( ) { val canQuote: Boolean get() = !busy && !submitted && !authenticationRequired && warning == null + + fun submissionError(status: UsdtTransferStatus?): Int? { + if (status == null || status == UsdtTransferStatus.CONFIRMED) return null + val expiredFailure = submissionDeadlineExpired && submissionErrorStatus == UsdtTransferStatus.PENDING && + status == UsdtTransferStatus.FAILED + return error.takeIf { status == submissionErrorStatus || expiredFailure } + } } private fun UsdtQuote.warnings(balance: ULong?, warnOver100: Boolean): List = buildList { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt index 07f16c65a1..2f295a4667 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt @@ -285,7 +285,8 @@ fun UsdtWalletScreen( maximumAmount = it } }, - onConfirm = { viewModel.confirm() }, onDone = dismiss, + onConfirm = { viewModel.confirm() }, onRetryRecovery = { viewModel.retryRecovery() }, + onDone = dismiss, hideBalance = settings.hideBalance, onDetails = { selectedTransferId = state.quote?.id diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 05ed1b131e..c37b95d466 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -272,13 +272,22 @@ class WalletViewModel @Inject constructor( pubkyRepo.initialize() } - private suspend fun restoreFromRNRemoteBackup() = runSuspendCatching { - migrationService.restoreFromRNRemoteBackup() - walletRepo.loadFromCache() - }.onFailure { - Logger.warn("RN remote backup restore failed, falling back to VSS", it, context = TAG) - backupRepo.performFullRestoreFromLatestBackup(onCacheRestored = walletRepo::loadFromCache) - .getOrThrow() + private suspend fun restoreFromRNRemoteBackup() { + val legacyRestore = runSuspendCatching { + migrationService.restoreFromRNRemoteBackup() + walletRepo.loadFromCache() + } + if (legacyRestore.isSuccess) { + backupRepo.restoreUsdtRecoveryAfterLegacyMigration().getOrThrow() + } else { + Logger.warn( + "RN remote backup restore failed, falling back to VSS", + legacyRestore.exceptionOrNull(), + context = TAG, + ) + backupRepo.performFullRestoreFromLatestBackup(onCacheRestored = walletRepo::loadFromCache) + .getOrThrow() + } } fun onRestoreContinue() { diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 0894e7c3af..23f52e9523 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -795,7 +795,7 @@ تفعيل USDT؟ أدخل عنوانًا صالحًا للشبكة المحددة. أدخل مبلغًا صالحًا لا يتجاوز 6 منازل عشرية. - تعذر نسخ هذا الدفع احتياطيًا. ستُعاد المحاولة عند توفر النسخ الاحتياطي. + تعذر نسخ هذه الدفعة احتياطيًا. ستتم إعادة محاولة الدفعة الموقّعة نفسها تلقائيًا عند توفر النسخ الاحتياطي. إغلاق هذه الشاشة لا يلغيها. رصيد USDT غير كافٍ لتغطية المبلغ والرسوم. المبلغ صغير جدًا لتغطية تكاليف الجسر. جرّب مبلغًا أكبر. اضبط تاريخ جهازك ووقته تلقائيًا، ثم حاول مرة أخرى. diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index b051b9e1e7..854b902b00 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -819,7 +819,7 @@ ¿Activar USDT? Ingrese una dirección válida para la red seleccionada. Ingrese un monto válido con hasta 6 decimales. - No se pudo hacer una copia de seguridad de este pago. Se reintentará cuando la copia de seguridad esté disponible. + No se pudo hacer una copia de seguridad de este pago. Se reintentará automáticamente el mismo pago firmado cuando la copia de seguridad esté disponible. Cerrar esta pantalla no lo cancela. No tiene suficientes USDT para cubrir el monto y las comisiones. Monto demasiado bajo para cubrir los costos del puente. Pruebe con un monto mayor. Configure la fecha y la hora de su dispositivo en automático y vuelva a intentarlo. diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index f83790081a..bfcc033901 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -812,7 +812,7 @@ Activar USDT? Introdueix una adreça vàlida per a la xarxa seleccionada. Introdueix un import vàlid amb un màxim de 6 decimals. - No es pot fer la còpia de seguretat d\'aquest pagament. Es tornarà a intentar quan la còpia de seguretat estigui disponible. + No es pot fer la còpia de seguretat d’aquest pagament. Es tornarà a intentar automàticament el mateix pagament signat quan la còpia de seguretat estigui disponible. Tancar aquesta pantalla no el cancel·la. No tens prou USDT per cobrir l\'import i les tarifes. L\'import és massa petit per cobrir els costos del pont. Prova amb un import més gran. Configura la data i l\'hora del dispositiu en mode automàtic i torna-ho a provar. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index bb7a644e4b..ec26262d2b 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -816,7 +816,7 @@ Zapnout USDT? Zadejte platnou adresu pro vybranou síť. Zadejte platnou částku s nejvýše 6 desetinnými místy. - Tuto platbu nelze zálohovat. Záloha se zopakuje, jakmile bude zálohování dostupné. + Tuto platbu nelze zálohovat. Stejná podepsaná platba se automaticky zopakuje, jakmile bude zálohování dostupné. Zavřením této obrazovky ji nezrušíte. Nemáte dostatek USDT na pokrytí částky a poplatků. Částka je příliš malá na pokrytí nákladů mostu. Zkuste vyšší částku. Nastavte automatické datum a čas zařízení a zkuste to znovu. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index efc30cca07..862f4f1b0c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -814,7 +814,7 @@ USDT aktivieren? Gib eine gültige Adresse für das ausgewählte Netzwerk ein. Gib einen gültigen Betrag mit bis zu 6 Dezimalstellen ein. - Diese Zahlung kann nicht gesichert werden. Die Sicherung wird wiederholt, sobald sie verfügbar ist. + Diese Zahlung kann nicht gesichert werden. Dieselbe signierte Zahlung wird automatisch erneut versucht, sobald die Sicherung verfügbar ist. Das Schließen dieses Bildschirms storniert sie nicht. Nicht genug USDT für Betrag und Gebühren. Der Betrag ist zu klein, um die Bridge-Kosten zu decken. Versuche einen höheren Betrag. Stelle Datum und Uhrzeit deines Geräts auf automatisch und versuche es dann erneut. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 3800135d9c..273e174438 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -797,7 +797,7 @@ Ενεργοποίηση USDT; Εισάγαγε έγκυρη διεύθυνση για το επιλεγμένο δίκτυο. Εισάγαγε έγκυρο ποσό με έως 6 δεκαδικά ψηφία. - Δεν ήταν δυνατή η δημιουργία αντιγράφου ασφαλείας αυτής της πληρωμής. Θα γίνει νέα προσπάθεια όταν είναι διαθέσιμο το αντίγραφο ασφαλείας. + Δεν ήταν δυνατή η δημιουργία αντιγράφου ασφαλείας αυτής της πληρωμής. Η ίδια υπογεγραμμένη πληρωμή θα επαναληφθεί αυτόματα όταν γίνει διαθέσιμη η δημιουργία αντιγράφων ασφαλείας. Το κλείσιμο αυτής της οθόνης δεν την ακυρώνει. Δεν υπάρχουν αρκετά USDT για την κάλυψη του ποσού και των τελών. Το ποσό είναι πολύ μικρό για να καλύψει τα κόστη της γέφυρας. Δοκίμασε μεγαλύτερο ποσό. Ρύθμισε την ημερομηνία και την ώρα της συσκευής σου σε αυτόματη και δοκίμασε ξανά. diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index fc09657b40..558a020922 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -815,7 +815,7 @@ ¿Activar USDT? Introduce una dirección válida para la red seleccionada. Introduce una cantidad válida con hasta 6 decimales. - No se pudo hacer una copia de seguridad de este pago. Se reintentará cuando la copia de seguridad esté disponible. + No se pudo hacer una copia de seguridad de este pago. Se reintentará automáticamente el mismo pago firmado cuando la copia de seguridad esté disponible. Cerrar esta pantalla no lo cancela. No tienes suficientes USDT para cubrir la cantidad y las comisiones. Cantidad demasiado pequeña para cubrir los costes del puente. Prueba con una cantidad mayor. Configura la fecha y la hora de tu dispositivo en automático e inténtalo de nuevo. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5072785f9b..5224935de6 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -812,7 +812,7 @@ ¿Activar USDT? Introduzca una dirección válida para la red seleccionada. Introduzca una cantidad válida con hasta 6 decimales. - No se pudo hacer una copia de seguridad de este pago. Se reintentará cuando la copia de seguridad esté disponible. + No se pudo hacer una copia de seguridad de este pago. Se reintentará automáticamente el mismo pago firmado cuando la copia de seguridad esté disponible. Cerrar esta pantalla no lo cancela. No tiene suficientes USDT para cubrir la cantidad y las comisiones. Cantidad demasiado pequeña para cubrir los costes del puente. Pruebe con una cantidad mayor. Configure la fecha y la hora de su dispositivo en automático e inténtelo de nuevo. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 6948ef3d1e..06a89e56bd 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -816,7 +816,7 @@ Activer USDT ? Saisissez une adresse valide pour le réseau sélectionné. Saisissez un montant valide avec 6 décimales au maximum. - Impossible de sauvegarder ce paiement. Une nouvelle tentative aura lieu dès que la sauvegarde sera disponible. + Impossible de sauvegarder ce paiement. Le même paiement signé sera réessayé automatiquement dès que la sauvegarde sera disponible. Fermer cet écran ne l’annule pas. USDT insuffisants pour couvrir le montant et les frais. Montant trop faible pour couvrir les coûts du pont. Essayez un montant plus élevé. Réglez la date et l\'heure de votre appareil en automatique, puis réessayez. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1689758d4e..e0ebc9f571 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -815,7 +815,7 @@ Attivare USDT? Inserisci un indirizzo valido per la rete selezionata. Inserisci un importo valido con al massimo 6 decimali. - Impossibile eseguire il backup di questo pagamento. Verrà ritentato quando il backup sarà disponibile. + Impossibile eseguire il backup di questo pagamento. Lo stesso pagamento firmato verrà ritentato automaticamente quando il backup sarà disponibile. Chiudere questa schermata non lo annulla. USDT insufficienti per coprire importo e commissioni. Importo troppo basso per coprire i costi del bridge. Prova con un importo maggiore. Imposta data e ora del dispositivo in automatico, poi riprova. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 2cb52c4df6..de56a9473b 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -811,7 +811,7 @@ USDT inschakelen? Voer een geldig adres in voor het geselecteerde netwerk. Voer een geldig bedrag in met maximaal 6 decimalen. - Kan deze betaling niet back-uppen. Dit wordt opnieuw geprobeerd zodra de back-up beschikbaar is. + Kan geen back-up van deze betaling maken. Dezelfde ondertekende betaling wordt automatisch opnieuw geprobeerd zodra de back-up beschikbaar is. Dit scherm sluiten annuleert de betaling niet. Niet genoeg USDT voor het bedrag en de vergoedingen. Bedrag te klein om de bridgekosten te dekken. Probeer een hoger bedrag. Stel de datum en tijd van uw apparaat in op automatisch en probeer het opnieuw. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 6b90799070..64f2590831 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -818,7 +818,7 @@ Włączyć USDT? Wprowadź prawidłowy adres dla wybranej sieci. Wprowadź prawidłową kwotę z maksymalnie 6 miejscami po przecinku. - Nie można utworzyć kopii zapasowej tej płatności. Ponowna próba nastąpi, gdy kopia zapasowa będzie dostępna. + Nie można utworzyć kopii zapasowej tej płatności. Ta sama podpisana płatność zostanie automatycznie ponowiona, gdy kopia zapasowa będzie dostępna. Zamknięcie tego ekranu jej nie anuluje. Za mało USDT na pokrycie kwoty i opłat. Kwota jest za mała, aby pokryć koszty mostu. Spróbuj większej kwoty. Ustaw automatyczną datę i godzinę na urządzeniu, a następnie spróbuj ponownie. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 53396ccd4e..af9986b902 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -819,7 +819,7 @@ Ativar USDT? Digite um endereço válido para a rede selecionada. Digite um valor válido com até 6 casas decimais. - Não foi possível fazer backup deste pagamento. Uma nova tentativa será feita quando o backup estiver disponível. + Não foi possível fazer backup deste pagamento. O mesmo pagamento assinado será tentado novamente de forma automática quando o backup estiver disponível. Fechar esta tela não o cancela. USDT insuficiente para cobrir o valor e as taxas. Valor muito baixo para cobrir os custos da ponte. Tente um valor maior. Configure a data e a hora do dispositivo como automáticas e tente novamente. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 33a4fed7a7..e49a4a0d16 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1019,7 +1019,7 @@ Ativar USDT? Introduza um endereço válido para a rede selecionada. Introduza um montante válido com até 6 casas decimais. - Não foi possível fazer cópia de segurança deste pagamento. Será tentado novamente quando a cópia de segurança estiver disponível. + Não foi possível fazer uma cópia de segurança deste pagamento. O mesmo pagamento assinado será tentado novamente de forma automática quando a cópia de segurança estiver disponível. Fechar este ecrã não o cancela. USDT insuficiente para cobrir o montante e as taxas. Montante demasiado baixo para cobrir os custos da ponte. Tente um montante maior. Defina a data e a hora do dispositivo como automáticas e tente novamente. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 1b60527452..7f841901c8 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -837,7 +837,7 @@ Включить USDT? Введите действительный адрес для выбранной сети. Введите корректную сумму, не более 6 знаков после запятой. - Не удалось создать резервную копию этого платежа. Попытка повторится, когда резервное копирование станет доступно. + Не удалось создать резервную копию этого платежа. Тот же подписанный платёж будет автоматически отправлен повторно, когда резервное копирование станет доступно. Закрытие этого экрана не отменяет платёж. Недостаточно USDT для оплаты суммы и комиссий. Сумма слишком мала для покрытия расходов моста. Попробуйте сумму побольше. Включите автоматическую установку даты и времени на устройстве и попробуйте ещё раз. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3e6434bbe..ab3c0af5e3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1269,7 +1269,7 @@ Enable USDT? Enter a valid address for the selected network. Enter a valid amount with up to 6 decimal places. - Unable to back up this payment. It will be retried when backup is available. + Unable to back up this payment. The same signed payment will retry automatically when backup is available. Closing this screen does not cancel it. Not enough USDT to cover the amount and fees. Amount too small to cover bridge costs. Try a larger amount. This route cannot complete the transfer right now. Try another network or try again later. diff --git a/app/src/test/java/to/bitkit/data/backup/UsdtRecoveryBackupStoreTest.kt b/app/src/test/java/to/bitkit/data/backup/UsdtRecoveryBackupStoreTest.kt new file mode 100644 index 0000000000..1f64f5597c --- /dev/null +++ b/app/src/test/java/to/bitkit/data/backup/UsdtRecoveryBackupStoreTest.kt @@ -0,0 +1,134 @@ +package to.bitkit.data.backup + +import com.synonym.bitkitcore.UsdtException +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.test.runTest +import org.junit.Test +import to.bitkit.models.PaykitUsdtStateBackup +import java.io.IOException +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class UsdtRecoveryBackupStoreTest { + private val payments = PaykitUsdtStateBackup(emptyList(), emptyList()) + + @Test + fun `independent writers preserve all snapshots and complete wallet backup`() = runTest { + val remote = mutableMapOf("WALLET" to "complete-wallet-backup".encodeToByteArray()) + val first = store(remote) + val second = store(remote) + awaitAll( + async { first.persist("signed-one", payments) }, + async { second.persist("signed-two", payments) }, + ) + assertContentEquals("complete-wallet-backup".encodeToByteArray(), remote["WALLET"]) + assertEquals(setOf("signed-one", "signed-two"), store(remote).snapshots().map { it.usdtWallet }.toSet()) + } + + @Test + fun `uncertain acknowledgement fails then restarted retry writes the same record`() = runTest { + val remote = mutableMapOf() + val interrupted = UsdtRecoveryBackupStore( + put = { key, data -> + remote[key] = data + throw IOException("acknowledgement lost") + }, + keys = { remote.keys.toList() }, + get = { remote[it] }, + ) + assertFailsWith { interrupted.persist("same-signed-payment", payments) } + val restarted = store(remote) + restarted.persist("same-signed-payment", payments) + assertEquals(1, remote.size) + assertEquals("same-signed-payment", restarted.snapshots().single().usdtWallet) + } + + @Test + fun `failed upload retains previous acknowledged snapshots`() = runTest { + val remote = mutableMapOf() + store(remote).persist("previous", payments) + val unavailable = UsdtRecoveryBackupStore( + put = { _, _ -> throw IOException("VSS unavailable") }, + keys = { remote.keys.toList() }, + get = { remote[it] }, + ) + assertFailsWith { unavailable.persist("pending", payments) } + assertEquals(listOf("previous"), store(remote).snapshots().map { it.usdtWallet }) + } + + @Test + fun `unknown future versions cannot be silently skipped`() = runTest { + val remote = mutableMapOf("USDT_RECOVERY_V2_future" to "future".encodeToByteArray()) + assertFailsWith { store(remote).snapshots() } + } + + @Test + fun `missing and corrupt records fail restore`() = runTest { + val remote = mutableMapOf() + store(remote).persist("signed", payments) + val missing = UsdtRecoveryBackupStore(put = { _, _ -> }, keys = { remote.keys.toList() }, get = { null }) + assertFailsWith { missing.snapshots() } + remote[remote.keys.single()] = "corrupt".encodeToByteArray() + assertFailsWith { store(remote).snapshots() } + } + + @Test + fun `interrupted migration replays legacy and every journal snapshot after restart`() = runTest { + val remote = mutableMapOf() + store(remote).persist("signed-one", payments) + store(remote).persist("signed-two", payments) + for (failureAt in 0 until 6) { + var step = 0 + val restored = mutableSetOf() + assertFailsWith { + store(remote).restore( + legacyWallet = "legacy", + legacyPaymentState = payments, + restoreWallet = { + if (step++ == failureAt) throw IOException("Interrupted wallet restore") + restored.add(it) + }, + restorePayments = { + if (step++ == failureAt) throw IOException("Interrupted association restore") + }, + ) + } + store(remote).restore( + legacyWallet = "legacy", + legacyPaymentState = payments, + restoreWallet = { restored.add(it) }, + restorePayments = {}, + ) + assertEquals(setOf("legacy", "signed-one", "signed-two"), restored) + } + } + + @Test + fun `iOS and Android envelope encodings restore with their original content hashes`() = runTest { + val android = "{\"version\":1,\"usdtWallet\":\"{\\\"identity\\\":\\\"42161:0x11111111111111" + + "11111111111111111111111111\\\",\\\"transfers\\\":[]}\",\"paykitUsdtState\"" + + ":{\"attempts\":[],\"receipts\":[]}}" + val ios = "{\"paykitUsdtState\":{\"attempts\":[],\"receipts\":[]},\"usdtWallet\":\"{\\" + + "\"identity\\\":\\\"42161:0x1111111111111111111111111111111111111111\\\"," + + "\\\"transfers\\\":[]}\",\"version\":1}" + val remote = mutableMapOf( + "USDT_RECOVERY_V1_c3b84c73c7a42be59f530edc24ed338ebb15475f10229a7c3fe49125beb532c2" to + android.encodeToByteArray(), + "USDT_RECOVERY_V1_54006dcc1f1a5af1d8c31954fee58bc3ab35f5c3c0d3cb81b63e4fd7c3ea9c5c" to + ios.encodeToByteArray(), + ) + val restored = store(remote).snapshots() + assertEquals(2, restored.size) + val wallet = """{"identity":"42161:0x1111111111111111111111111111111111111111","transfers":[]}""" + assertEquals(setOf(wallet), restored.map { it.usdtWallet }.toSet()) + assertEquals(listOf(payments, payments), restored.map { it.paykitUsdtState }) + } + + private fun store(remote: MutableMap) = UsdtRecoveryBackupStore( + put = { key, data -> remote[key] = data }, + keys = { remote.keys.toList() }, + get = { remote[it] }, + ) +} diff --git a/app/src/test/java/to/bitkit/env/BackupTestEndpointsTest.kt b/app/src/test/java/to/bitkit/env/BackupTestEndpointsTest.kt new file mode 100644 index 0000000000..ea07e37035 --- /dev/null +++ b/app/src/test/java/to/bitkit/env/BackupTestEndpointsTest.kt @@ -0,0 +1,43 @@ +package to.bitkit.env + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class BackupTestEndpointsTest { + private val vss = "http://localhost:18657/vss" + private val auth = "http://localhost:18655/auth" + + @Test + fun `disabled fixture ignores even incomplete configuration`() { + assertNull(BackupTestEndpoints.resolve(false, vss, null)) + } + + @Test + fun `empty configuration preserves normal endpoints`() { + assertNull(BackupTestEndpoints.resolve(true, null, " ")) + } + + @Test + fun `valid explicit pair is trimmed and retained`() { + assertEquals(BackupTestEndpoints(vss, auth), BackupTestEndpoints.resolve(true, " $vss ", auth)) + } + + @Test + fun `invalid or partial pair never mixes fixture and normal auth`() { + listOf( + null, + "", + "https://example.com/auth", + "http://127.0.0.1:18655/auth", + "http://localhost/auth", + "http://user@localhost:18655/auth", + "http://localhost:18655/auth?token=x", + "http://localhost:18655/auth#fragment" + ).forEach { invalid -> + assertFailsWith { BackupTestEndpoints.resolve(true, vss, invalid) } + assertFailsWith { BackupTestEndpoints.resolve(true, invalid, auth) } + } + } +} diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 5e192dbd06..cd7289587f 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -23,6 +23,7 @@ import org.junit.Before import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.doSuspendableAnswer @@ -48,6 +49,8 @@ import to.bitkit.data.WatchOnlyAccountData import to.bitkit.data.WatchOnlyAccountStore import to.bitkit.data.WidgetsData import to.bitkit.data.WidgetsStore +import to.bitkit.data.backup.UsdtRecoveryBackupStore +import to.bitkit.data.backup.UsdtRecoveryBackupV1 import to.bitkit.data.backup.VssBackupClient import to.bitkit.data.backup.VssBackupClientLdk import to.bitkit.data.dao.TransferDao @@ -185,6 +188,7 @@ class BackupRepoTest : BaseUnitTest() { privatePaykitAddressReservationRepo.reconcileReservedIndexesWithLdk() }.thenReturn(Result.success(Unit)) + whenever { vssBackupClient.listKeys() }.thenReturn(Result.success(emptyList())) sut = createSut() } @@ -901,14 +905,36 @@ class BackupRepoTest : BaseUnitTest() { verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) } + @Test + fun `Pubky unavailable does not prevent acknowledged USDT recovery backup`() = test { + whenever(privatePaykitRepo.backupSnapshot()).thenReturn( + Result.failure(PaykitException.SharedStateBusy("shared_state_busy", "lock unavailable")), + ) + assertTrue(sut.triggerBackup(BackupCategory.WALLET).isFailure) + sut.persistUsdtRecoveryBackup("same-signed-operation").getOrThrow() + val payload = argumentCaptor() + verify(vssBackupClient).putObject(argThat { startsWith(UsdtRecoveryBackupStore.PREFIX) }, payload.capture()) + assertEquals( + "same-signed-operation", + json.decodeFromString(payload.firstValue.decodeToString()).usdtWallet, + ) + verify(privatePaykitRepo, times(1)).backupSnapshot() + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + } + @Test fun `payment backup waits for earlier uploads and propagates failure`() = test { val release = CompletableDeferred() val uploads = mutableListOf() var fail = false whenever(usdtRepo.backupSnapshot()).thenReturn("older") - whenever { vssBackupClient.putObject(eq(BackupCategory.WALLET.name), any()) }.doSuspendableAnswer { - val payload = json.decodeFromString(it.getArgument(1).decodeToString()) + whenever { + vssBackupClient.putObject( + argThat { startsWith(UsdtRecoveryBackupStore.PREFIX) }, + any(), + ) + }.doSuspendableAnswer { + val payload = json.decodeFromString(it.getArgument(1).decodeToString()) if (payload.usdtWallet == "older") release.await() uploads += payload.usdtWallet if (fail) { @@ -917,9 +943,9 @@ class BackupRepoTest : BaseUnitTest() { Result.success(VssItem(key = BackupCategory.WALLET.name, value = byteArrayOf(), version = 1)) } } - val background = async { sut.triggerBackup(BackupCategory.WALLET) } + val background = async { sut.triggerBackup(BackupCategory.USDT_RECOVERY) } runCurrent() - val payment = async { sut.persistWalletBackup("signed") } + val payment = async { sut.persistUsdtRecoveryBackup("signed") } runCurrent() assertTrue(uploads.isEmpty()) release.complete(Unit) @@ -927,7 +953,7 @@ class BackupRepoTest : BaseUnitTest() { payment.await().getOrThrow() assertEquals(listOf("older", "signed"), uploads.toList()) fail = true - assertTrue(sut.persistWalletBackup("signed").isFailure) + assertTrue(sut.persistUsdtRecoveryBackup("signed").isFailure) } @Test @@ -936,12 +962,69 @@ class BackupRepoTest : BaseUnitTest() { .thenReturn(Result.failure(BackupRepoTestError("offline"))) .thenReturn(Result.success(Unit)) - assertTrue(sut.persistWalletBackup("signed").isFailure) + assertTrue(sut.persistUsdtRecoveryBackup("signed").isFailure) verify(vssBackupClient, never()).putObject(any(), any()) - sut.persistWalletBackup("signed").getOrThrow() + sut.persistUsdtRecoveryBackup("signed").getOrThrow() verify(vssBackupClient, times(2)).setup(any()) - verify(vssBackupClient).putObject(eq(BackupCategory.WALLET.name), any()) + verify(vssBackupClient).putObject(argThat { startsWith(UsdtRecoveryBackupStore.PREFIX) }, any()) + } + + @Test + fun `restart after legacy migration restores only pending USDT state`() = test { + whenever(keychain.exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + whenever(keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)).thenReturn(false) + sut.performFullRestoreFromLatestBackup().getOrThrow() + verify(keychain).delete(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name) + verify(privatePaykitRepo, never()).restoreBackup(anyOrNull()) + verify(vssBackupClient, never()).getObject(BackupCategory.METADATA.name) + verify(vssBackupClient, never()).getObject(BackupCategory.SETTINGS.name) + verify(keychain, never()).upsertString(eq(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name), any()) + } + + @Test + fun `failed journal import retains legacy recovery and blocks replacement across restart`() = test { + val key = Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name + var retained: String? = null + whenever(keychain.exists(key)).thenAnswer { retained != null } + whenever(keychain.loadString(key)).thenAnswer { retained } + whenever(keychain.upsertString(eq(key), any())).thenAnswer { + retained = it.getArgument(1) + Unit + } + whenever(keychain.delete(key)).thenAnswer { + retained = null + Unit + } + stubWalletBackup(usdtWallet = "legacy-signed-operation") + whenever(vssBackupClient.listKeys()).thenReturn(Result.failure(BackupRepoTestError("offline"))) + + assertTrue(sut.restoreUsdtRecoveryAfterLegacyMigration().isFailure) + assertEquals( + "legacy-signed-operation", + json.decodeFromString(requireNotNull(retained)).usdtWallet, + ) + assertTrue(sut.persistWalletBackup().isFailure) + assertTrue(sut.triggerBackup(BackupCategory.WALLET).isFailure) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + verify(usdtRepo, never()).restoreBackup(any()) + + sut = createSut() + assertTrue(sut.hasPendingWalletRestore()) + assertTrue(sut.persistWalletBackup().isFailure) + whenever(vssBackupClient.getObject(BackupCategory.WALLET.name)) + .thenReturn(Result.failure(BackupRepoTestError("remote wallet changed"))) + whenever(vssBackupClient.listKeys()).thenReturn(Result.success(emptyList())) + whenever(usdtRepo.restoreBackup("legacy-signed-operation")).thenReturn(Result.success(Unit)) + + sut.performFullRestoreFromLatestBackup().getOrThrow() + + assertNull(retained) + verify(usdtRepo).restoreBackup("legacy-signed-operation") + verify(vssBackupClient, times(1)).getObject(BackupCategory.WALLET.name) + verify(privatePaykitRepo, never()).restoreBackup(anyOrNull()) + verify(vssBackupClient, never()).getObject(BackupCategory.METADATA.name) + verify(keychain, never()).upsertString(eq(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name), any()) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoRecoveryTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoRecoveryTest.kt index 308ba9574c..d472f8d47c 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoRecoveryTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoRecoveryTest.kt @@ -32,6 +32,7 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import to.bitkit.data.keychain.Keychain import to.bitkit.di.json @@ -47,8 +48,10 @@ import to.bitkit.services.UsdtService import to.bitkit.test.BaseUnitTest import to.bitkit.viewmodels.ContactPaymentContext import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.ExperimentalTime @@ -138,6 +141,37 @@ class PaykitUsdtPaymentRepoRecoveryTest : BaseUnitTest() { endpoints = listOf(PaykitUsdt.endpoint(address)) ) + @Test + fun `reconciliation waits for recovery restore without accessing payment state`() = test { + whenever(usdt.isRecoveryRestorePending).thenReturn(true) + + assertTrue(repo().reconcile().isSuccess) + + verifyNoInteractions(sdk, proofs, service, wallet) + assertNull(stored) + } + + @Test + fun `restore retains proof only payment guard across restart and rejects conflicting binding`() = test { + val source = repo() + source.prepare(context(), quote, amount, request.payment(MethodId.UsdtArbitrum, clock.now())).getOrThrow() + val original = source.backupSnapshot().attempts.single() + val proof = PaykitUsdtProof(UsdtPaymentProof("42161", "transaction", "0", "signature")) + source.restoreBackup( + PaykitUsdtStateBackup(listOf(original.copy(proof = proof, proofQueued = true)), emptyList()), + ) + val restarted = repo() + val restored = restarted.backupSnapshot().attempts.single() + assertTrue(restored.paymentStarted) + assertNull(restored.proof) + assertFalse(restored.proofQueued) + assertTrue(restarted.hasBinding(request.id)) + assertFailsWith { + restarted.restoreBackup(PaykitUsdtStateBackup(listOf(original.copy(contact = identity)), emptyList())) + } + assertEquals(restored, restarted.backupSnapshot().attempts.single()) + } + @Test fun `request protection survives restart and releases only after definite failure`() = test { val repo = repo() @@ -361,7 +395,7 @@ class PaykitUsdtPaymentRepoRecoveryTest : BaseUnitTest() { } @Test - fun `preparing the same quote preserves proof and restored progress`() = test { + fun `preparing the same quote preserves restored progress without trusting imported proof`() = test { val repo = repo() val terms = request.payment(MethodId.UsdtArbitrum, clock.now()) repo.prepare(context(), quote, amount, terms).getOrThrow() @@ -371,12 +405,13 @@ class PaykitUsdtPaymentRepoRecoveryTest : BaseUnitTest() { ) repo.restoreBackup(PaykitUsdtStateBackup(listOf(funded), emptyList())) repo.prepare(context(), quote, amount, terms).getOrThrow() - assertEquals(listOf(funded), repo.backupSnapshot().attempts) + val restored = funded.copy(proof = null) + assertEquals(listOf(restored), repo.backupSnapshot().attempts) assertEquals( PaykitPaymentRequestError.OperationInProgress, repo.prepare(context(), quote.copy(id = "replacement"), amount, terms).exceptionOrNull(), ) - assertEquals(listOf(funded), repo.backupSnapshot().attempts) + assertEquals(listOf(restored), repo.backupSnapshot().attempts) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt b/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt index 38b530957f..a50a7808d3 100644 --- a/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt @@ -39,6 +39,39 @@ class UsdtRepoTest : BaseUnitTest() { on { this.expiresAt }.thenReturn(expiresAt) } + @Test + fun `recovery retries the recorded operation and respects provider cooldown`() = test { + var now = Instant.fromEpochSeconds(1_800_000_000) + val clock = object : Clock { override fun now() = now } + whenever(service.wallet()).thenReturn(wallet) + whenever(transfer.id).thenReturn("original") + whenever(transfer.status).thenReturn(UsdtTransferStatus.PENDING) + whenever(wallet.history()).thenReturn(listOf(transfer)) + whenever(wallet.refreshTransfers()).thenThrow(UsdtException.RateLimited()).thenReturn(listOf(transfer)) + val repo = UsdtRepo(service, testDispatcher, clock) + assertTrue(repo.retryTransfer("original").exceptionOrNull() is UsdtException.RateLimited) + now += 59.seconds + assertTrue(repo.retryTransfer("original").exceptionOrNull() is UsdtException.RateLimited) + verify(wallet, times(1)).refreshTransfers() + now += 1.seconds + repo.retryTransfer("original").getOrThrow() + assertEquals(listOf(transfer), repo.state.value.transfers) + assertEquals(0L, repo.recoveryGeneration) + assertTrue(repo.retryTransfer("unknown").exceptionOrNull() is UsdtException.InvalidBackup) + whenever(transfer.status).thenReturn(UsdtTransferStatus.CONFIRMED) + repo.retryTransfer("original").getOrThrow() + assertEquals(0L, repo.recoveryGeneration) + verify(wallet, times(2)).refreshTransfers() + } + + @Test + fun `acknowledgement generation reads the current service value before any refresh`() { + val repo = UsdtRepo(service, testDispatcher) + whenever(service.recoveryGeneration).thenReturn(4L, 5L) + assertEquals(4L, repo.recoveryGeneration) + assertEquals(5L, repo.recoveryGeneration) + } + @Test fun `quote rate limits pause manual payment retries and refresh until the cooldown expires`() = test { var now = Instant.fromEpochSeconds(1_800_000_000) diff --git a/app/src/test/java/to/bitkit/services/UsdtServiceTest.kt b/app/src/test/java/to/bitkit/services/UsdtServiceTest.kt new file mode 100644 index 0000000000..8d8be87697 --- /dev/null +++ b/app/src/test/java/to/bitkit/services/UsdtServiceTest.kt @@ -0,0 +1,29 @@ +package to.bitkit.services + +import com.synonym.bitkitcore.UsdtException +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.repositories.BackupRepo +import to.bitkit.test.BaseUnitTest +import javax.inject.Provider +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class UsdtServiceTest : BaseUnitTest() { + @Test + fun `recovery generation advances only after the actual backup callback succeeds`() = test { + val backups: BackupRepo = mock() + val service = UsdtService(mock(), Provider { backups }) + whenever(backups.persistUsdtRecoveryBackup("original")) + .thenReturn(Result.failure(UsdtException.NetworkUnavailable()), Result.success(Unit)) + + assertFailsWith { service.recoveryBackup.persist("original") } + assertEquals(0L, service.recoveryGeneration) + service.recoveryBackup.persist("original") + assertEquals(1L, service.recoveryGeneration) + verify(backups, times(2)).persistUsdtRecoveryBackup("original") + } +} diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 203f6163ea..898a9e08a6 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -401,6 +401,23 @@ class WalletViewModelTest : BaseUnitTest() { verify(backupRepo, never()).performFullRestoreFromLatestBackup(any()) } + @Test + fun `newer RN restore still restores USDT recovery and never falls back after a USDT failure`() = test { + whenever(migrationService.getRNRemoteBackupTimestamp()).thenReturn(2uL) + whenever(backupRepo.getLatestBackupTime()).thenReturn(1uL) + whenever( + backupRepo.restoreUsdtRecoveryAfterLegacyMigration(), + ).thenReturn(Result.failure(AppError("VSS offline"))) + whenever(walletRepo.restoreWallet(any(), anyOrNull())).thenReturn(Result.success(Unit)) + sut.restoreWallet("mnemonic", "passphrase") + walletState.value = walletState.value.copy(walletExists = true) + advanceUntilIdle() + assertEquals(RestoreState.BackupFailed(1), sut.restoreState.value) + verify(migrationService).restoreFromRNRemoteBackup() + verify(backupRepo).restoreUsdtRecoveryAfterLegacyMigration() + verify(backupRepo, never()).performFullRestoreFromLatestBackup(any()) + } + @Test fun `pending wallet restore retry bypasses newer RN backup`() = test { whenever(backupRepo.hasPendingWalletRestore()).thenReturn(true) diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt index dc5f3c92ca..faafff36c3 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt @@ -5,6 +5,7 @@ import com.synonym.bitkitcore.UsdtException import com.synonym.bitkitcore.UsdtPaymentRequest import com.synonym.bitkitcore.UsdtQuote import com.synonym.bitkitcore.UsdtTransfer +import com.synonym.bitkitcore.UsdtTransferStatus import com.synonym.bitkitcore.usdtFormatAmount import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CancellationException @@ -263,6 +264,7 @@ class UsdtViewModelTest : BaseUnitTest() { private val repo: UsdtRepo = mock() private val settingsStore: SettingsStore = mock() private val walletState = MutableStateFlow(UsdtWalletState()) + private var recoveryGeneration = 0L private val settings = MutableStateFlow(SettingsData(isPinEnabled = true, isPinForPaymentsEnabled = true)) private val quote = UsdtQuote( id = "payment", @@ -481,8 +483,11 @@ class UsdtViewModelTest : BaseUnitTest() { @Test fun `durably recorded send failure retains the payment and prevents resubmission`() = test { + // A previously acknowledged payment must not clear this payment's failed backup. + recoveryGeneration = 4L val retained: UsdtTransfer = mock() whenever(retained.id).thenReturn(quote.id) + whenever(retained.status).thenReturn(UsdtTransferStatus.PENDING) val viewModel = createViewModel(walletState = UsdtWalletState(transfers = persistentListOf(retained))) settings.value = settings.value.copy(isPinForPaymentsEnabled = false) whenever(repo.send(eq(quote), anyOrNull(), any())) @@ -492,13 +497,105 @@ class UsdtViewModelTest : BaseUnitTest() { viewModel.confirm().join() assertTrue(viewModel.state.value.submitted) - assertTrue(viewModel.state.value.busy) + assertFalse(viewModel.state.value.busy) assertEquals(quote, viewModel.state.value.quote) - assertNull(viewModel.state.value.error) + assertEquals(R.string.usdt__error_backup, viewModel.state.value.error) viewModel.waitForTransfer() assertFalse(viewModel.state.value.busy) + viewModel.refresh() + assertEquals(R.string.usdt__error_backup, viewModel.state.value.error) + whenever(repo.retryTransfer(quote.id)).thenReturn(Result.failure(UsdtException.BackupUnavailable())) + viewModel.retryRecovery().join() + assertEquals(R.string.usdt__error_backup, viewModel.state.value.error) + assertTrue(viewModel.state.value.canRetryRecovery) + whenever(repo.retryTransfer(quote.id)).thenReturn(Result.failure(UsdtException.RateLimited())) + viewModel.retryRecovery().join() + assertEquals(R.string.usdt__error_rate_limit, viewModel.state.value.error) + assertTrue(viewModel.state.value.canRetryRecovery) + whenever(repo.retryTransfer(quote.id)).thenReturn(Result.success(Unit)) + viewModel.retryRecovery().join() + assertEquals(R.string.usdt__error_rate_limit, viewModel.state.value.error) + assertTrue(viewModel.state.value.canRetryRecovery) + viewModel.refresh() + assertTrue(viewModel.state.value.canRetryRecovery) + recoveryGeneration++ + viewModel.retryRecovery().join() + assertNull(viewModel.state.value.error) + assertFalse(viewModel.state.value.canRetryRecovery) + assertEquals(quote, viewModel.state.value.quote) + viewModel.confirm().join() + verify(repo, times(1)).quote(any(), any(), any(), any()) + verify(repo, times(4)).retryTransfer(quote.id) + verify(repo, times(1)).send(eq(quote), anyOrNull(), any()) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `closing during recovery retry keeps its failure out of a new send form`() = test { + val retained: UsdtTransfer = mock() + whenever(retained.id).thenReturn(quote.id) + whenever(retained.status).thenReturn(UsdtTransferStatus.PENDING) + val viewModel = createViewModel(walletState = UsdtWalletState(transfers = persistentListOf(retained))) + settings.value = settings.value.copy(isPinForPaymentsEnabled = false) + whenever(repo.send(eq(quote), anyOrNull(), any())) + .thenReturn(Result.failure(UsdtException.BackupUnavailable())) + viewModel.quote(quote.recipient, "1", quote.destination).join() + viewModel.confirm().join() + val completion = CompletableDeferred>() + whenever(repo.retryTransfer(quote.id)).doSuspendableAnswer { completion.await() } + + val retry = viewModel.retryRecovery() + advanceUntilIdle() + assertTrue(viewModel.state.value.retryingRecovery) + viewModel.edit() + val newForm = viewModel.state.value + completion.complete(Result.failure(UsdtException.BackupUnavailable())) + retry.join() + + assertEquals(newForm, viewModel.state.value) + verify(repo, times(1)).retryTransfer(quote.id) + verify(repo, times(1)).send(eq(quote), anyOrNull(), any()) + } + + @Test + fun `recovery by another observer clears the latest retry failure without a new payment`() = test { + val retained: UsdtTransfer = mock() + whenever(retained.id).thenReturn(quote.id) + whenever(retained.status).thenReturn(UsdtTransferStatus.PENDING) + val viewModel = createViewModel(walletState = UsdtWalletState(transfers = persistentListOf(retained))) + settings.value = settings.value.copy(isPinForPaymentsEnabled = false) + whenever(repo.send(eq(quote), anyOrNull(), any())) + .thenReturn(Result.failure(UsdtException.BackupUnavailable())) + viewModel.quote(quote.recipient, "1", quote.destination).join() + viewModel.confirm().join() + assertEquals(R.string.usdt__error_backup, viewModel.state.value.error) + whenever(repo.retryTransfer(quote.id)).thenReturn(Result.failure(UsdtException.RateLimited())) + viewModel.retryRecovery().join() + assertEquals(R.string.usdt__error_rate_limit, viewModel.state.value.error) + assertTrue(viewModel.state.value.canRetryRecovery) + + recoveryGeneration++ + viewModel.refresh() + + assertNull(viewModel.state.value.error) + assertFalse(viewModel.state.value.canRetryRecovery) + assertEquals(quote, viewModel.state.value.quote) viewModel.confirm().join() verify(repo, times(1)).send(eq(quote), anyOrNull(), any()) + verify(repo, times(1)).retryTransfer(quote.id) + } + + @Test + fun `submission feedback follows the retained status and disappears on confirmation`() { + val state = UsdtSendState( + error = R.string.usdt__error_backup, + submitted = true, + submissionErrorStatus = UsdtTransferStatus.PENDING, + ) + assertEquals(R.string.usdt__error_backup, state.submissionError(UsdtTransferStatus.PENDING)) + assertNull(state.submissionError(UsdtTransferStatus.CONFIRMED)) + assertNull(state.submissionError(UsdtTransferStatus.FAILED)) + assertNull(state.submissionError(null)) } @Test @@ -523,7 +620,10 @@ class UsdtViewModelTest : BaseUnitTest() { @Test fun `recorded payment that exceeds its deadline retains recovery with an expiry error`() = test { - val retained: UsdtTransfer = mock { on { id }.thenReturn(quote.id) } + val retained: UsdtTransfer = mock { + on { id }.thenReturn(quote.id) + on { status }.thenReturn(UsdtTransferStatus.PENDING) + } val model = createViewModel(walletState = UsdtWalletState(transfers = persistentListOf(retained))) settings.value = settings.value.copy(isPinForPaymentsEnabled = false) whenever(repo.send(eq(quote), anyOrNull(), any())) @@ -533,6 +633,20 @@ class UsdtViewModelTest : BaseUnitTest() { assertTrue(model.state.value.submitted) assertEquals(quote, model.state.value.quote) assertEquals(R.string.wallet__payment_request_expired, model.state.value.error) + assertEquals( + R.string.wallet__payment_request_expired, + model.state.value.submissionError(UsdtTransferStatus.PENDING) + ) + assertEquals( + R.string.wallet__payment_request_expired, + model.state.value.submissionError(UsdtTransferStatus.FAILED) + ) + for (status in UsdtTransferStatus.entries.filterNot { + it == UsdtTransferStatus.PENDING || it == UsdtTransferStatus.FAILED + }) { + assertNull(model.state.value.submissionError(status)) + } + assertNull(model.state.value.submissionError(null)) model.confirm().join() verify(repo, times(1)).send(eq(quote), anyOrNull(), any()) } @@ -637,6 +751,7 @@ class UsdtViewModelTest : BaseUnitTest() { ): UsdtViewModel { this.walletState.value = walletState whenever(repo.state).thenReturn(this.walletState) + whenever(repo.recoveryGeneration).thenAnswer { recoveryGeneration } whenever(settingsStore.data).thenReturn(settingsFlow) whenever(repo.quote(any(), any(), any(), any())).thenReturn(Result.success(quote)) whenever(repo.send(eq(quote), anyOrNull(), any())).thenReturn(Result.success(mock())) diff --git a/changelog.d/next/1681.fixed.md b/changelog.d/next/1681.fixed.md new file mode 100644 index 0000000000..352945293d --- /dev/null +++ b/changelog.d/next/1681.fixed.md @@ -0,0 +1 @@ +Keep ordinary USDT recovery backups independent of Pubky and resume the same signed payment after backup failures. diff --git a/docs/usdt-recovery-backups.md b/docs/usdt-recovery-backups.md new file mode 100644 index 0000000000..bf3b008fd0 --- /dev/null +++ b/docs/usdt-recovery-backups.md @@ -0,0 +1,22 @@ +# USDT recovery backups + +Ordinary USDT submissions save their signed recovery snapshot through encrypted VSS independently of the full wallet backup. The callback returns successfully only after VSS acknowledges the write. A failure leaves the original signed operation in local storage; both automatic recovery and the send screen’s Retry action reconcile and resume it. Closing the screen does not cancel a retained operation. Paykit authorization, request deadlines and proof submission still apply to Paykit payments. + +Each immutable `USDT_RECOVERY_V1_` record contains a version, the core USDT snapshot and its local Paykit USDT associations. The hash covers the exact JSON bytes; readers accept both native serializers’ field ordering. VSS encrypts and obfuscates the key. Readers list decrypted names and verify every selected record’s version and content hash. Missing, corrupt or unsupported records stop restoration. A write never replaces the complete `WALLET` envelope or another recovery record. + +The full `WALLET` backup remains a separate, complete snapshot with fresh Paykit SDK state. Temporary storage failures leave it pending and retry with exponential delays capped at five minutes. This backup retry does not replay a payment mutation. Paykit’s shared-state recovery and proof deduplication remain responsible for reconciling uncertain mutation outcomes under a fresh lease. + +Restoration first persists a USDT recovery gate, waits for in-flight operations, and reads the complete selected journal before applying it. Existing `WALLET` USDT data is merged before the journal. The downloaded legacy payload is retained locally before journal import, and full `WALLET` replacement stays blocked while the USDT restore gate is pending. A restart repeats the merge while network recovery stays gated. Conflicting payment bindings fail closed. Restored proof evidence retains the started-payment guard but is rechecked against canonical chain state before being used as payment completion. The USDT gate clears only after core state and associations are restored. The full wallet’s separate pending restore continues to protect its unrelated state. + +When a React Native backup is selected for wallet migration, its wallet metadata is retained and the USDT VSS recovery phase still runs. A failure in that phase resumes only USDT recovery on restart; it must not replace the already restored metadata with an older VSS wallet snapshot. + +## Compatibility and limits + +- Existing complete `WALLET` backups remain readable and are still written by the full backup routine. Their format is unchanged. +- Older app versions do not understand the independent journal. Downgrading can recover only USDT state included in the last successful complete `WALLET` backup. Do not treat downgrade recovery as supported for newer journal-only operations. +- Records are not pruned. Full snapshots can grow storage and restore work as history grows. Safe compaction needs a protocol that preserves every writer’s acknowledged records; unconditional VSS writes cannot provide that guarantee by replacing one key. +- Immutable records prevent concurrent writers from overwriting each other’s recovery data. They do not coordinate concurrent spending or make a multi-page VSS listing an atomic snapshot. + +## Verification + +The controlled core tests exercise actual signed-operation persistence, backup failure before broadcast, exact-operation recovery, deadlines, restart and merge ordering. Native tests cover independent backup wiring, envelope compatibility, lost acknowledgements, concurrent records, interrupted migration, pending backup retry and presentation. The `usdt-independent-recovery.xml` journey describes the complete device checks. It requires a controlled funded fixture and fault injection; passing unit tests is not evidence that this journey ran. Record the executed revision, results and any unavailable fixtures in the PR. diff --git a/docs/usdt.md b/docs/usdt.md index 4fcdb51f35..2422313467 100644 --- a/docs/usdt.md +++ b/docs/usdt.md @@ -1,6 +1,6 @@ # USDT configuration -Normal builds use published bitkit-core `0.8.0-rc6`, including its matching native libraries and generated bindings. +Normal builds use published bitkit-core `0.8.0-rc11`, including its matching native libraries and generated bindings. USDT uses Arbitrum One regardless of the Bitcoin network setting. Builds use the credential-free Bitkit gateway by default: @@ -57,9 +57,9 @@ Receipts require successful canonical execution, the correct chain/token/recipie ## Encrypted recovery backups -The wallet backup includes core's portable USDT recovery snapshot alongside Paykit request bindings, pending proofs and received-payment identities. It contains signed operations and stable activity IDs, but no private keys or seed. The existing VSS client encrypts and authenticates the complete envelope. History cursors and unsigned quotes are rebuilt locally. +Before broadcast, the app saves core's portable USDT recovery snapshot alongside local Paykit USDT request bindings, pending proofs and received-payment identities in an independent encrypted VSS record. This does not export unrelated Paykit state or acquire a Pubky lock. The complete wallet backup remains separate. Recovery snapshots contain signed operations and stable activity IDs, but no private keys or seed; history cursors and unsigned quotes are rebuilt locally. See [USDT recovery backups](usdt-recovery-backups.md) for restore ordering and compatibility limits. -Core waits for the app to acknowledge a VSS upload before first submission or automatic rebroadcast. If backup is unavailable, the signed payment stays pending locally and no submission is attempted; recovery retries the same operation once backup is available, or reconciles its expiry against the chain. Wallet uploads are serialized so an older background snapshot cannot replace a payment's acknowledged backup. +Core waits for the app to acknowledge a VSS upload before first submission or automatic rebroadcast. If backup is unavailable, the signed payment stays pending locally and no submission is attempted; recovery retries the same operation once backup is available, or reconciles its expiry against the chain. Recovery uploads use immutable records so an older background wallet snapshot cannot replace an acknowledged payment backup. Restore merges records for the derived account without overwriting newer local outcomes. Signed operations imported into an empty database are reconciled against chain evidence before retry; already discovered transactions retain their restored application IDs. A failed restore retains the downloaded envelope and blocks replacement backups. Bitcoin-only backups remain readable. Seed-only recovery restores account control and chain history, but cannot reconstruct a never-mined signed operation or its Paykit attribution. Missing evidence never authorizes a second payment. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1b857067ed..354b2f3f65 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,8 +21,8 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.8.0-rc10" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.1-rc1" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.8.0-rc11" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.1-rc2" } bouncycastle-pkix-jdk = { module = "org.bouncycastle:bcpkix-jdk18on", version = "1.83" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } @@ -92,7 +92,7 @@ test-junit-ext = { module = "androidx.test.ext:junit", version = "1.3.0" } test-mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version = "6.2.2" } test-robolectric = { module = "org.robolectric:robolectric", version = "4.16.1" } test-turbine = { group = "app.cash.turbine", name = "turbine", version = "1.2.1" } -vss-client = { module = "com.synonym:vss-client-android", version = "0.5.23" } +vss-client = { module = "com.synonym:vss-client-android", version = "0.5.26-rc1" } webkit = { module = "androidx.webkit:webkit", version = "1.16.0" } work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.0" } zxing = { module = "com.google.zxing:core", version = "3.5.4" } diff --git a/journeys/README.md b/journeys/README.md index 5647c3bef5..f423cb4ac1 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -281,3 +281,5 @@ tab's display name and becomes `Tab-todas` in Spanish. Journeys naming a `Tab-*` English device for iOS's sake. Device-clock fault injection requires a separate manual run: [Paykit clock changes](paykit-clock-changes.md). + +`usdt-independent-recovery.xml` is shared by iOS and Android with the same journey name and actions. It covers independent acknowledged recovery, Pubky lock contention, backup failure, same-operation retry, restart, migration and concurrent writers. It requires disposable local USDT and fault-injection endpoints; the standard staging environment does not supply those fixtures. A missing fixture is a verification gap, not a passed journey. diff --git a/journeys/usdt-independent-recovery.xml b/journeys/usdt-independent-recovery.xml new file mode 100644 index 0000000000..cd72598a30 --- /dev/null +++ b/journeys/usdt-independent-recovery.xml @@ -0,0 +1,13 @@ + + Requires an onboarded wallet using an owned local test seed and recipient, controlled USDT RPC/bundler, VSS storage and Pubky homeserver fault injection. These fixtures are not supplied by the standard journey environment. Report blocked when they are unavailable. Record operation IDs, signed payloads and bundler submissions. Restart and restore only these disposable test wallets. + + Make Pubky unavailable while VSS works. Send a small ordinary USDT payment. Verify its recovery backup is acknowledged before the bundler receives the signed operation, and the payment can confirm without Pubky. Verify the existing complete wallet backup remains readable. + Reject the USDT backup. Approve one payment and verify Error explains the backup failure, that the same signed payment will retry and that closing the screen does not cancel it. Verify Pending, Details, Retry and Close are available and the bundler has received no operation. + Press Retry twice while backup remains unavailable, then close and reopen the app. Verify one retained payment with the original operation ID and signed payload, no new quote or approval, and no broadcast. Allow VSS acknowledgement and retry; verify recovery submits only that original signed operation. Repeat with a lost VSS acknowledgement and with automatic recovery without pressing Retry. + Keep a recovery Retry waiting for VSS, close its payment screen and tap Send to open a fresh recipient form. Let the old retry fail. Verify its backup explanation does not appear in the new form, the original transfer remains pending and no new payment is approved or submitted. Cancel the new form. + Hold the Pubky write lock temporarily during Paykit backup or proof delivery. Verify the pending work remains retained, retries slow down to a bounded interval, and recovery after release reconciles the remote outcome before writing again. Restart while the work is pending; verify no duplicate payment request, proof or payment appears. Verify a genuine Paykit payment still requires its authorization and payment-request checks. + Restore the disposable wallet after each failed backup, lost acknowledgement and Pubky-lock failure. Interrupt restoration after core state and after payment associations, then restart. Verify sending stays blocked until all required USDT records are restored and the original payment can resume without another payment. Include both existing complete wallet backups and the independent USDT records. + With two disposable instances of the same fixture wallet, upload different recovery snapshots and change payment associations during an upload. Restore on both platforms and verify every acknowledged transfer remains recoverable, started-payment guards survive, and stale proofs are checked again before use. If the older-format wallet backup is selected, verify newer USDT records are restored without replacing the selected wallet metadata. + Repeat with a fixture payment request whose submission deadline expires after its transfer is saved. Retain the same transfer while recovery changes it from Pending to Failed. Verify the payment-request expiration title and explanation remain visible and no new operation is created. If execution evidence instead confirms or replaces the transfer, verify that status replaces the expiration explanation. + +