diff --git a/.claude/commands/pr.md b/.claude/commands/pr.md index d5b507c1c..928aac570 100644 --- a/.claude/commands/pr.md +++ b/.claude/commands/pr.md @@ -1,6 +1,6 @@ --- description: Create a PR on GitHub for the current branch -argument_hint: "[branch] [--dry] [--draft]" +argument_hint: "[branch] [--dry] [--draft] [-- instructions]" allowed_tools: Bash, Read, Glob, Grep, Write, AskUserQuestion, mcp__github__create_pull_request, mcp__github__list_pull_requests, mcp__github__get_file_contents, mcp__github__issue_read --- @@ -12,6 +12,8 @@ Create a PR on GitHub using the `gh` CLI for the currently checked-out branch. - `/pr --dry` - Generate description only, save to `.ai/` - `/pr --draft` - Create as draft PR - `/pr develop --draft` - Draft PR against non-default branch +- `/pr -- focus on commit abc123` - With custom instructions for description +- `/pr master --draft -- describe migration test in QA` - Combined with all options ## Steps @@ -24,6 +26,7 @@ Run `gh pr view --json number,url 2>/dev/null` to check if a PR already exists f - `--dry`: Skip PR creation, only generate and save description - `--draft`: Create PR as draft - First non-flag argument: base branch (default: auto-detected, see Step 2.5) +- Everything after `--` separator (if present): Custom instructions for PR generation - **If no flags provided**: Use `AskUserQuestion` to prompt user: - Open PR (create and publish) - Draft PR (create as draft) @@ -42,6 +45,10 @@ If no base branch argument provided, detect the repo's default branch: - Fetch 10 most recent PRs (open or closed) from the extracted repo for writing style reference - Run `git log $base..HEAD --oneline` for commit messages - Run `git diff $base...HEAD --stat` for understanding scope of changes +- **If custom instructions provided:** + - If instructions reference a specific commit SHA (pattern like `commit [a-f0-9]{7,40}`): + - Read full commit message: `git log -1 --format='%B' ` + - Store instructions for use in description generation ### 4. Extract Linked Issues Scan commits for issue references: @@ -102,6 +109,14 @@ This PR adds support for... - Minimize code and file references like `TheClassName` or `someFunctionName`, `thisFileName.ext` - Exception: for refactoring PRs (1:10 ratio of functionality to code changes), more technical detail is ok +**Custom Instructions (if provided):** +When the user provides custom instructions after `--`: +- Parse any referenced commit SHAs and read their full messages +- Focus the description content on areas the user emphasizes +- Structure QA Notes according to user's specific testing instructions +- Custom instructions take priority over default generation rules for sections they address +- Preserve exact testing steps provided by the user (don't summarize or omit details) + **QA Notes / Testing Scenarios:** - Structure with numbered headings and steps - Make steps easily referenceable diff --git a/app/src/main/java/to/bitkit/data/dto/price/TradingPair.kt b/app/src/main/java/to/bitkit/data/dto/price/TradingPair.kt index ab4ffcd37..0e3b76ace 100644 --- a/app/src/main/java/to/bitkit/data/dto/price/TradingPair.kt +++ b/app/src/main/java/to/bitkit/data/dto/price/TradingPair.kt @@ -15,5 +15,3 @@ enum class TradingPair( val ticker: String get() = "$base$quote" } - -fun String.displayNameToTradingPair() = TradingPair.entries.firstOrNull { it.displayName == this } diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index ca804b284..97ef0ce33 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -23,14 +23,22 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put +import org.lightningdevkit.ldknode.Network +import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.WidgetsStore +import to.bitkit.data.dao.TransferDao +import to.bitkit.data.dto.price.GraphPeriod +import to.bitkit.data.dto.price.TradingPair +import to.bitkit.data.entities.TransferEntity import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin import to.bitkit.di.json @@ -40,98 +48,46 @@ import to.bitkit.models.CoinSelectionPreference import to.bitkit.models.PrimaryDisplay import to.bitkit.models.Suggestion import to.bitkit.models.TransactionSpeed +import to.bitkit.models.TransferType import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition +import to.bitkit.models.safe +import to.bitkit.models.widget.BlocksPreferences +import to.bitkit.models.widget.FactsPreferences +import to.bitkit.models.widget.HeadlinePreferences +import to.bitkit.models.widget.PricePreferences +import to.bitkit.models.widget.WeatherPreferences import to.bitkit.repositories.ActivityRepo import to.bitkit.services.core.Bip39Service +import to.bitkit.utils.AppError import to.bitkit.utils.Logger import java.io.File import java.security.KeyStore import javax.inject.Inject import javax.inject.Singleton -private val Context.rnMigrationDataStore: DataStore by preferencesDataStore( - name = "rn_migration" -) - -private val Context.rnKeychainDataStore: DataStore by preferencesDataStore( - name = "RN_KEYCHAIN" -) - -@Serializable -private data class RNRemoteActivityItem( - val id: String, - val activityType: String, - val txType: String, - val txId: String? = null, - val value: Long, - val fee: Long? = null, - val feeRate: Long? = null, - val address: String? = null, - val confirmed: Boolean? = null, - val timestamp: Long, - val isBoosted: Boolean? = null, - val isTransfer: Boolean? = null, - val exists: Boolean? = null, - val confirmTimestamp: Long? = null, - val channelId: String? = null, - val transferTxId: String? = null, - val status: String? = null, - val message: String? = null, - val preimage: String? = null, - val boostedParents: List? = null, -) - -@Serializable -private data class RNRemoteWalletBackup( - val transfers: Map>? = null, - val boostedTransactions: Map>? = null, -) - -@Serializable -private data class RNRemoteTransfer(val txId: String? = null, val type: String? = null) - -@Serializable -private data class RNRemoteBoostedTx( - val oldTxId: String? = null, - val newTxId: String? = null, - val childTransaction: String? = null, -) - -@Serializable -private data class RNRemoteBlocktankBackup( - val orders: List? = null, - val paidOrders: List? = null, -) - -@Serializable -private data class RNRemoteBlocktankOrder( - val id: String, - val state: String? = null, - val lspBalanceSat: ULong? = null, - val clientBalanceSat: ULong? = null, - val channelExpiryWeeks: Int? = null, - val createdAt: String? = null, -) - -@Suppress("LargeClass", "TooManyFunctions") +@Suppress("LargeClass", "TooManyFunctions", "LongParameterList") @Singleton class MigrationService @Inject constructor( @ApplicationContext private val context: Context, private val keychain: Keychain, private val settingsStore: SettingsStore, private val widgetsStore: WidgetsStore, + private val cacheStore: CacheStore, private val activityRepo: ActivityRepo, private val coreService: CoreService, private val rnBackupClient: RNBackupClient, private val bip39Service: Bip39Service, + private val transferDao: TransferDao, ) { companion object { private const val TAG = "Migration" const val RN_MIGRATION_COMPLETED_KEY = "rnMigrationCompleted" const val RN_MIGRATION_CHECKED_KEY = "rnMigrationChecked" + private const val OPENING_CURLY_BRACE = "{" + private const val MMKV_ROOT = "persist:root" private const val RN_WALLET_NAME = "wallet0" - private const val MILLISECONDS_TO_SECONDS = 1000 + private const val MS_PER_SEC = 1000 private const val GCM_IV_LENGTH = 12 private const val GCM_TAG_LENGTH = 128 } @@ -147,16 +103,12 @@ class MigrationService @Inject constructor( private val _isShowingMigrationLoading = MutableStateFlow(false) val isShowingMigrationLoading: StateFlow = _isShowingMigrationLoading.asStateFlow() - fun setShowingMigrationLoading(value: Boolean) { - _isShowingMigrationLoading.update { value } - } + fun setShowingMigrationLoading(value: Boolean) = _isShowingMigrationLoading.update { value } private val _isRestoringFromRNRemoteBackup = MutableStateFlow(false) val isRestoringFromRNRemoteBackup: StateFlow = _isRestoringFromRNRemoteBackup.asStateFlow() - fun setRestoringFromRNRemoteBackup(value: Boolean) { - _isRestoringFromRNRemoteBackup.update { value } - } + fun setRestoringFromRNRemoteBackup(value: Boolean) = _isRestoringFromRNRemoteBackup.update { value } @Volatile private var pendingChannelMigration: PendingChannelMigration? = null @@ -167,9 +119,7 @@ class MigrationService @Inject constructor( return migration } - fun peekPendingChannelMigration(): PendingChannelMigration? { - return pendingChannelMigration - } + fun peekPendingChannelMigration(): PendingChannelMigration? = pendingChannelMigration @Volatile private var pendingRemoteActivityData: List? = null @@ -183,40 +133,35 @@ class MigrationService @Inject constructor( @Volatile private var pendingRemoteMetadata: RNMetadata? = null - private val rnNetworkString: String - get() = when (Env.network) { - org.lightningdevkit.ldknode.Network.BITCOIN -> "bitcoin" - org.lightningdevkit.ldknode.Network.REGTEST -> "bitcoinRegtest" - org.lightningdevkit.ldknode.Network.TESTNET -> "bitcoinTestnet" - org.lightningdevkit.ldknode.Network.SIGNET -> "signet" - } + @Volatile + private var pendingRemotePaidOrders: Map? = null - private val rnLdkBasePath: File - get() = File(context.filesDir, "ldk") + private fun buildRnLdkAccountPath(): File = run { + val rnNetworkString = when (Env.network) { + Network.BITCOIN -> "bitcoin" + Network.REGTEST -> "bitcoinRegtest" + Network.TESTNET -> "bitcoinTestnet" + Network.SIGNET -> "signet" + } + val rnLdkBasePath = File(context.filesDir, "ldk") - private val rnLdkAccountPath: File - get() { - val accountName = buildString { - append(RN_WALLET_NAME) - append(rnNetworkString) - append("ldkaccountv3") - } - return File(rnLdkBasePath, accountName) + @Suppress("SpellCheckingInspection") + val accountName = buildString { + append(RN_WALLET_NAME) + append(rnNetworkString) + append("ldkaccountv3") } - private val rnMmkvPath: File - get() = File(context.filesDir, "mmkv/mmkv.default") + return File(rnLdkBasePath, accountName) + } + + private fun getRnMmkvPath(): File = File(context.filesDir, "mmkv/mmkv.default") suspend fun isMigrationChecked(): Boolean { val key = stringPreferencesKey(RN_MIGRATION_CHECKED_KEY) return rnMigrationStore.data.first()[key] == "true" } - suspend fun isMigrationCompleted(): Boolean { - val key = stringPreferencesKey(RN_MIGRATION_COMPLETED_KEY) - return rnMigrationStore.data.first()[key] == "true" - } - suspend fun markMigrationChecked() { val key = stringPreferencesKey(RN_MIGRATION_CHECKED_KEY) rnMigrationStore.edit { it[key] = "true" } @@ -224,44 +169,26 @@ class MigrationService @Inject constructor( suspend fun hasRNWalletData(): Boolean { val mnemonic = loadStringFromRNKeychain(RNKeychainKey.MNEMONIC) - if (mnemonic?.isNotEmpty() == true) { - return true - } + if (mnemonic?.isNotEmpty() == true) return true return hasRNMmkvData() || hasRNLdkData() } - @Suppress("TooGenericExceptionCaught", "SwallowedException") - suspend fun hasNativeWalletData(): Boolean { - return try { - keychain.exists(Keychain.Key.BIP39_MNEMONIC.name) - } catch (e: Exception) { - false - } - } - - suspend fun hasRNLdkData(): Boolean { - return File(rnLdkAccountPath, "channel_manager.bin").exists() - } + fun hasNativeWalletData() = runCatching { keychain.exists(Keychain.Key.BIP39_MNEMONIC.name) }.getOrDefault(false) + fun hasRNLdkData() = File(buildRnLdkAccountPath(), "channel_manager.bin").exists() + fun hasRNMmkvData() = getRnMmkvPath().exists() - suspend fun hasRNMmkvData(): Boolean { - return rnMmkvPath.exists() - } - - @Suppress("TooGenericExceptionCaught") private suspend fun loadStringFromRNKeychain(key: RNKeychainKey): String? { val datastorePath = File(context.filesDir, "datastore/RN_KEYCHAIN.preferences_pb") - if (!datastorePath.exists()) { - return null - } + if (!datastorePath.exists()) return null - return try { + return runCatching { val preferences = context.rnKeychainDataStore.data.first() val passwordKey = stringPreferencesKey("${key.service}:p") val cipherKey = stringPreferencesKey("${key.service}:c") - val encryptedValue = preferences[passwordKey] ?: return null + val encryptedValue = preferences[passwordKey] ?: return@runCatching null val cipherInfo = preferences[cipherKey] val fullEncryptedValue = if (cipherInfo != null && !encryptedValue.contains(":")) { @@ -270,22 +197,19 @@ class MigrationService @Inject constructor( encryptedValue } decryptRNKeychainValue(fullEncryptedValue, key.service) - } catch (e: Exception) { - Logger.error("Error reading from RN_KEYCHAIN DataStore: $e", e, context = TAG) - null - } + }.onFailure { + Logger.error("Error reading from RN_KEYCHAIN DataStore: $it", it, context = TAG) + }.getOrNull() } - @Suppress("TooGenericExceptionCaught") private fun decryptRNKeychainValue(encryptedValue: String, service: String): String? { if (!encryptedValue.contains(":")) { - return try { + return runCatching { val encryptedBytes = android.util.Base64.decode(encryptedValue, android.util.Base64.DEFAULT) decryptWithKeystore(encryptedBytes, service) - } catch (e: Exception) { - Logger.error("Failed to decrypt without cipher prefix: $e", e, context = TAG) - null - } + }.onFailure { + Logger.error("Failed to decrypt without cipher prefix: $it", it, context = TAG) + }.getOrNull() } val parts = encryptedValue.split(":", limit = 2) @@ -297,16 +221,14 @@ class MigrationService @Inject constructor( return null } - return try { + return runCatching { val encryptedBytes = android.util.Base64.decode(encryptedDataBase64, android.util.Base64.DEFAULT) decryptWithKeystore(encryptedBytes, service) - } catch (e: Exception) { - Logger.error("Failed to decrypt RN keychain value: $e", e, context = TAG) - null - } + }.onFailure { + Logger.error("Failed to decrypt RN keychain value: $it", it, context = TAG) + }.getOrNull() } - @Suppress("TooGenericExceptionCaught") private fun decryptWithKeystore(encryptedBytes: ByteArray, service: String): String? { if (encryptedBytes.size < GCM_IV_LENGTH) { Logger.error("Encrypted data too short: ${encryptedBytes.size} bytes", context = TAG) @@ -316,16 +238,15 @@ class MigrationService @Inject constructor( val iv = encryptedBytes.sliceArray(0 until GCM_IV_LENGTH) val ciphertext = encryptedBytes.sliceArray(GCM_IV_LENGTH until encryptedBytes.size) - return try { + return runCatching { val keystore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - val alias = service - if (!keystore.containsAlias(alias)) { - Logger.error("Keystore alias '$alias' not found", context = TAG) - return null + if (!keystore.containsAlias(service)) { + Logger.error("Keystore alias '$service' not found", context = TAG) + return@runCatching null } - val secretKey = keystore.getKey(alias, null) as javax.crypto.SecretKey + val secretKey = keystore.getKey(service, null) as javax.crypto.SecretKey val transformation = "AES/GCM/NoPadding" val spec = javax.crypto.spec.GCMParameterSpec(GCM_TAG_LENGTH, iv) @@ -335,77 +256,56 @@ class MigrationService @Inject constructor( val decryptedBytes = cipher.doFinal(ciphertext) String(decryptedBytes, Charsets.UTF_8) - } catch (e: Exception) { - Logger.error("Failed to decrypt with Keystore: $e", e, context = TAG) - null - } + }.onFailure { + Logger.error("Failed to decrypt with Keystore: $it", it, context = TAG) + }.getOrNull() } - @Suppress("TooGenericExceptionCaught") - suspend fun migrateFromReactNative() { + suspend fun migrateFromReactNative() = runCatching { setShowingMigrationLoading(true) - try { - val mnemonicMigrated = try { - migrateMnemonic() - true - } catch (e: Exception) { - Logger.warn( - "Could not migrate mnemonic: $e. User will need to manually restore.", - context = TAG - ) - false - } + val mnemonicMigrated = runCatching { migrateMnemonic() }.map { true }.onFailure { + Logger.warn("Could not migrate mnemonic: $it. User will need to manually restore.", context = TAG) + }.getOrDefault(false) - if (mnemonicMigrated) { - migratePassphrase() - migratePin() - - if (hasRNLdkData()) { - migrateLdkData() - .onFailure { e -> - Logger.warn( - "LDK data migration failed, continuing with other migrations: $e", - e, - context = TAG - ) - } - } + if (mnemonicMigrated) { + migratePassphrase() + migratePin() - if (hasRNMmkvData()) { - migrateMMKVData() + if (hasRNLdkData()) { + migrateLdkData().onFailure { + Logger.warn("LDK data migration failed, continuing with other migrations: $it", it, context = TAG) } + } - rnMigrationStore.edit { - it[stringPreferencesKey(RN_MIGRATION_COMPLETED_KEY)] = "true" - it[stringPreferencesKey(RN_MIGRATION_CHECKED_KEY)] = "true" - } - } else { - markMigrationChecked() - setShowingMigrationLoading(false) - throw to.bitkit.utils.AppError( - "Migration data unavailable. Please restore your wallet using your recovery phrase." - ) + if (hasRNMmkvData()) { + migrateMMKVData() + } + + rnMigrationStore.edit { + it[stringPreferencesKey(RN_MIGRATION_COMPLETED_KEY)] = "true" + it[stringPreferencesKey(RN_MIGRATION_CHECKED_KEY)] = "true" } - } catch (e: Exception) { - Logger.error("RN migration failed: $e", e, context = TAG) + } else { markMigrationChecked() setShowingMigrationLoading(false) - throw e + throw AppError("Migration data unavailable. Please restore your wallet using your recovery phrase.") } - } + }.onFailure { + Logger.error("RN migration failed: $it", it, context = TAG) + markMigrationChecked() + setShowingMigrationLoading(false) + }.getOrThrow() private suspend fun migrateMnemonic() { val mnemonic = loadStringFromRNKeychain(RNKeychainKey.MNEMONIC) if (mnemonic.isNullOrEmpty()) { - throw to.bitkit.utils.AppError( - "Migration data unavailable. Please restore your wallet using your recovery phrase." - ) + throw AppError("Migration data unavailable. Please restore your wallet using your recovery phrase.") } bip39Service.validateMnemonic(mnemonic).onFailure { - throw to.bitkit.utils.AppError( + throw AppError( "Recovery phrase is invalid. Please use your 12 or 24 word recovery phrase to restore manually." ) } @@ -415,17 +315,13 @@ class MigrationService @Inject constructor( private suspend fun migratePassphrase() { val passphrase = loadStringFromRNKeychain(RNKeychainKey.PASSPHRASE) - if (passphrase.isNullOrEmpty()) { - return - } + if (passphrase.isNullOrEmpty()) return keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, passphrase) } private suspend fun migratePin() { val pin = loadStringFromRNKeychain(RNKeychainKey.PIN) - if (pin.isNullOrEmpty()) { - return - } + if (pin.isNullOrEmpty()) return if (pin.length != Env.PIN_LENGTH) { Logger.warn( @@ -443,8 +339,8 @@ class MigrationService @Inject constructor( keychain.saveString(Keychain.Key.PIN.name, pin) } - private suspend fun migrateLdkData() = runCatching { - val accountPath = rnLdkAccountPath + private fun migrateLdkData() = runCatching { + val accountPath = buildRnLdkAccountPath() val managerPath = File(accountPath, "channel_manager.bin") if (!managerPath.exists()) { @@ -469,117 +365,104 @@ class MigrationService @Inject constructor( channelManager = managerData, channelMonitors = monitors, ) - }.onFailure { e -> - Logger.error("Failed to migrate LDK data: $e", e, context = TAG) + }.onFailure { + Logger.error("Failed to migrate LDK data: $it", it, context = TAG) } - suspend fun loadRNMmkvData(): Map? = runCatching { - if (!hasRNMmkvData()) { - return@runCatching null - } + fun loadRNMmkvData(): Map? = runCatching { + if (!hasRNMmkvData()) return@runCatching null - val data = rnMmkvPath.readBytes() + val data = getRnMmkvPath().readBytes() val parser = MmkvParser(data) parser.parse().takeIf { it.isNotEmpty() } - }.onFailure { e -> - Logger.error("Failed to read MMKV data: $e", e, context = TAG) + }.onFailure { + Logger.error("Failed to read MMKV data: $it", it, context = TAG) }.getOrNull() - @Suppress("TooGenericExceptionCaught") - private suspend fun extractRNSettings(mmkvData: Map): RNSettings? { - val rootJson = mmkvData["persist:root"] ?: return null + private fun extractRNSettings(mmkvData: Map): RNSettings? { + val rootJson = mmkvData[MMKV_ROOT] ?: return null - return try { - val jsonStart = rootJson.indexOf("{") + return runCatching { + val jsonStart = rootJson.indexOf(OPENING_CURLY_BRACE) val jsonString = if (jsonStart >= 0) rootJson.substring(jsonStart) else rootJson val root = json.parseToJsonElement(jsonString).jsonObject - val settingsJsonString = root["settings"]?.jsonPrimitive?.content ?: return null + val settingsJsonString = root["settings"]?.jsonPrimitive?.content ?: return@runCatching null json.decodeFromString(settingsJsonString) - } catch (e: Exception) { - Logger.error("Failed to decode RN settings: $e", e, context = TAG) - null - } + }.onFailure { + Logger.error("Failed to decode RN settings: $it", it, context = TAG) + }.getOrNull() } - @Suppress("TooGenericExceptionCaught") - private suspend fun extractRNMetadata(mmkvData: Map): RNMetadata? { - val rootJson = mmkvData["persist:root"] ?: return null + private fun extractRNMetadata(mmkvData: Map): RNMetadata? { + val rootJson = mmkvData[MMKV_ROOT] ?: return null - return try { - val jsonStart = rootJson.indexOf("{") + return runCatching { + val jsonStart = rootJson.indexOf(OPENING_CURLY_BRACE) val jsonString = if (jsonStart >= 0) rootJson.substring(jsonStart) else rootJson val root = json.parseToJsonElement(jsonString).jsonObject - val metadataJsonString = root["metadata"]?.jsonPrimitive?.content ?: return null + val metadataJsonString = root["metadata"]?.jsonPrimitive?.content ?: return@runCatching null json.decodeFromString(metadataJsonString) - } catch (e: Exception) { - Logger.error("Failed to decode RN metadata: $e", e, context = TAG) - null - } + }.onFailure { + Logger.error("Failed to decode RN metadata: $it", it, context = TAG) + }.getOrNull() } - @Suppress("TooGenericExceptionCaught") - private suspend fun extractRNTodos(mmkvData: Map): RNTodos? { - val rootJson = mmkvData["persist:root"] ?: return null + private fun extractRNTodos(mmkvData: Map): RNTodos? { + val rootJson = mmkvData[MMKV_ROOT] ?: return null - return try { - val jsonStart = rootJson.indexOf("{") + return runCatching { + val jsonStart = rootJson.indexOf(OPENING_CURLY_BRACE) val jsonString = if (jsonStart >= 0) rootJson.substring(jsonStart) else rootJson val root = json.parseToJsonElement(jsonString).jsonObject - val todosJsonString = root["todos"]?.jsonPrimitive?.content ?: return null + val todosJsonString = root["todos"]?.jsonPrimitive?.content ?: return@runCatching null json.decodeFromString(todosJsonString) - } catch (e: Exception) { - Logger.error("Failed to decode RN todos: $e", e, context = TAG) - null - } + }.onFailure { + Logger.error("Failed to decode RN todos: $it", it, context = TAG) + }.getOrNull() } - @Suppress("TooGenericExceptionCaught") - private suspend fun extractRNWidgets(mmkvData: Map): RNWidgetsWithOptions? { - val rootJson = mmkvData["persist:root"] ?: return null + private fun extractRNWidgets(mmkvData: Map): RNWidgetsWithOptions? { + val rootJson = mmkvData[MMKV_ROOT] ?: return null - return try { - val jsonStart = rootJson.indexOf("{") + return runCatching { + val jsonStart = rootJson.indexOf(OPENING_CURLY_BRACE) val jsonString = if (jsonStart >= 0) rootJson.substring(jsonStart) else rootJson val root = json.parseToJsonElement(jsonString).jsonObject - val widgetsJsonString = root["widgets"]?.jsonPrimitive?.content ?: return null + val widgetsJsonString = root["widgets"]?.jsonPrimitive?.content + ?: return@runCatching null val widgets = json.decodeFromString(widgetsJsonString) val widgetsData = json.parseToJsonElement(widgetsJsonString).jsonObject - val widgetOptions = convertRNWidgetPreferences( - widgetsData["widgets"]?.jsonObject ?: widgetsData - ) + val widgetOptions = convertRNWidgetPreferences(widgetsData["widgets"]?.jsonObject ?: widgetsData) RNWidgetsWithOptions(widgets = widgets, widgetOptions = widgetOptions) - } catch (e: Exception) { - Logger.error("Failed to decode RN widgets: $e", e, context = TAG) - null - } + }.onFailure { + Logger.error("Failed to decode RN widgets: $it", it, context = TAG) + }.getOrNull() } - @Suppress("TooGenericExceptionCaught") - private suspend fun extractRNActivities(mmkvData: Map): List? { - val rootJson = mmkvData["persist:root"] ?: return null + private fun extractRNActivities(mmkvData: Map): List? { + val rootJson = mmkvData[MMKV_ROOT] ?: return null - return try { - val jsonStart = rootJson.indexOf("{") + return runCatching { + val jsonStart = rootJson.indexOf(OPENING_CURLY_BRACE) val jsonString = if (jsonStart >= 0) rootJson.substring(jsonStart) else rootJson val root = json.parseToJsonElement(jsonString).jsonObject - val activityJsonString = root["activity"]?.jsonPrimitive?.content ?: return null + val activityJsonString = root["activity"]?.jsonPrimitive?.content ?: return@runCatching null val activityState = json.decodeFromString(activityJsonString) activityState.items ?: emptyList() - } catch (e: Exception) { - Logger.error("Failed to decode RN activities: $e", e, context = TAG) - null - } + }.onFailure { + Logger.error("Failed to decode RN activities: $it", it, context = TAG) + }.getOrNull() } private fun extractTransfers(transfers: Map>?): Map { @@ -636,55 +519,82 @@ class MigrationService @Inject constructor( } } - @Suppress("TooGenericExceptionCaught") - private suspend fun extractRNWalletBackup( - mmkvData: Map - ): Pair, Map>? { - val rootJson = mmkvData["persist:root"] ?: return null + private fun extractRNWalletBackup(mmkvData: Map): Pair, Map>? { + val rootJson = mmkvData[MMKV_ROOT] ?: return null - return try { - val jsonStart = rootJson.indexOf("{") + return runCatching { + val jsonStart = rootJson.indexOf(OPENING_CURLY_BRACE) val jsonString = if (jsonStart >= 0) rootJson.substring(jsonStart) else rootJson val root = json.parseToJsonElement(jsonString).jsonObject - val walletJsonString = root["wallet"]?.jsonPrimitive?.content ?: return null + val walletJsonString = root["wallet"]?.jsonPrimitive?.content ?: return@runCatching null val walletData = json.parseToJsonElement(walletJsonString).jsonObject - val walletState = runCatching { - json.decodeFromJsonElement(walletData) - }.getOrNull() + val walletState = runCatching { json.decodeFromJsonElement(walletData) }.getOrNull() walletState?.let { extractFromWalletState(it) } ?: run { - val walletBackup = runCatching { - json.decodeFromJsonElement(walletData) - }.getOrNull() - walletBackup?.let { extractFromWalletBackup(it) } + runCatching { json.decodeFromJsonElement(walletData) }.getOrNull()?.let { + extractFromWalletBackup(it) + } } - } catch (e: Exception) { - Logger.error("Failed to decode RN wallet backup: $e", e, context = TAG) - null - } + }.onFailure { + Logger.error("Failed to decode RN wallet backup: $it", it, context = TAG) + }.getOrNull() } - @Serializable - private data class RNWalletState(val wallets: Map? = null) + private fun extractRNBlocktank(mmkvData: Map): Pair, Map>? { + val rootJson = mmkvData[MMKV_ROOT] ?: return null + + return runCatching { + val jsonStart = rootJson.indexOf(OPENING_CURLY_BRACE) + val jsonString = if (jsonStart >= 0) rootJson.substring(jsonStart) else rootJson + + val root = json.parseToJsonElement(jsonString).jsonObject + val blocktankJsonString = root["blocktank"]?.jsonPrimitive?.content ?: return@runCatching null + + val blocktankData = json.parseToJsonElement(blocktankJsonString).jsonObject + val orderIds = mutableListOf() + val paidOrdersMap = mutableMapOf() + + blocktankData["orders"]?.jsonArray?.forEach { orderElement -> + orderElement.jsonObject["id"]?.jsonPrimitive?.content?.let { id -> + orderIds.add(id) + } + } + + blocktankData["paidOrders"]?.jsonObject?.forEach { (orderId, txIdElement) -> + val txId = txIdElement.jsonPrimitive.content + paidOrdersMap[orderId] = txId + if (orderId !in orderIds) { + orderIds.add(orderId) + } + } + + if (orderIds.isEmpty() && paidOrdersMap.isEmpty()) { + return@runCatching null + } - @Serializable - private data class RNWalletData( - val transfers: Map>? = null, - val boostedTransactions: Map>? = null, - ) + Logger.info( + "Extracted ${orderIds.size} order IDs and ${paidOrdersMap.size} paid orders from local blocktank", + context = TAG, + ) + Pair(orderIds, paidOrdersMap) + }.onFailure { + Logger.error("Failed to decode RN blocktank: $it", it, context = TAG) + }.getOrNull() + } - @Suppress("NestedBlockDepth", "TooGenericExceptionCaught") - private suspend fun extractRNClosedChannels(mmkvData: Map): List? { - val rootJson = mmkvData["persist:root"] ?: return null + @Suppress("NestedBlockDepth") + private fun extractRNClosedChannels(mmkvData: Map): List? { + val rootJson = mmkvData[MMKV_ROOT] ?: return null - return try { - val jsonStart = rootJson.indexOf("{") + return runCatching { + val jsonStart = rootJson.indexOf(OPENING_CURLY_BRACE) val jsonString = if (jsonStart >= 0) rootJson.substring(jsonStart) else rootJson val root = json.parseToJsonElement(jsonString).jsonObject - val lightningJsonString = root["lightning"]?.jsonPrimitive?.content ?: return null + val lightningJsonString = root["lightning"]?.jsonPrimitive?.content + ?: return@runCatching null val lightningState = json.decodeFromString(lightningJsonString) val closedChannels = mutableListOf() @@ -700,10 +610,9 @@ class MigrationService @Inject constructor( } closedChannels.takeIf { it.isNotEmpty() } - } catch (e: Exception) { - Logger.error("Failed to decode RN lightning state: $e", e, context = TAG) - null - } + }.onFailure { + Logger.error("Failed to decode RN lightning state: $it", it, context = TAG) + }.getOrNull() } @Suppress("CyclomaticComplexMethod") @@ -761,16 +670,16 @@ class MigrationService @Inject constructor( activityId = it.id } ActivityTags(activityId = activityId, tags = tagList) - }.onFailure { e -> - Logger.error("Failed to get activity ID for $txId: $e", e, context = TAG) + }.onFailure { + Logger.error("Failed to get activity ID for $txId: $it", it, context = TAG) }.getOrNull() } ?: emptyList() if (allTags.isNotEmpty()) { runCatching { coreService.activity.upsertTags(allTags) - }.onFailure { e -> - Logger.error("Failed to migrate tags: $e", e, context = TAG) + }.onFailure { + Logger.error("Failed to migrate tags: $it", it, context = TAG) } } @@ -808,7 +717,7 @@ class MigrationService @Inject constructor( else -> PaymentState.PENDING } - val timestampSecs = (item.timestamp / MILLISECONDS_TO_SECONDS).toULong() + val timestampSecs = (item.timestamp / MS_PER_SEC).toULong() val invoice = item.address?.takeIf { it.isNotEmpty() } ?: "migrated:${item.id}" Activity.Lightning( @@ -835,14 +744,12 @@ class MigrationService @Inject constructor( } private suspend fun applyRNClosedChannels(channels: List) { - val now = (System.currentTimeMillis() / MILLISECONDS_TO_SECONDS).toULong() + val now = (System.currentTimeMillis() / MS_PER_SEC).toULong() val closedChannels = channels.mapNotNull { channel -> val fundingTxid = channel.fundingTxid ?: return@mapNotNull null - val closedAtSecs = channel.createdAt?.let { - (it / MILLISECONDS_TO_SECONDS).toULong() - } ?: now + val closedAtSecs = channel.createdAt?.let { (it / MS_PER_SEC).toULong() } ?: now val outboundMsat = (channel.outboundCapacitySat ?: 0u) * 1000u val inboundMsat = (channel.inboundCapacitySat ?: 0u) * 1000u @@ -906,51 +813,51 @@ class MigrationService @Inject constructor( } } - @Suppress("LongMethod", "CyclomaticComplexMethod", "TooGenericExceptionCaught") + @Suppress("LongMethod", "CyclomaticComplexMethod") private suspend fun applyRNWidgetPreferences(widgetOptions: Map) { widgetOptions["price"]?.let { priceData -> - try { - val priceJson = json.decodeFromString( + runCatching { + val priceJson = json.decodeFromString( priceData.decodeToString() ) val selectedPairs = priceJson["selectedPairs"]?.jsonArray?.mapNotNull { pairElement -> val pairStr = pairElement.jsonPrimitive.content.replace("_", "/") when (pairStr) { - "BTC/USD" -> to.bitkit.data.dto.price.TradingPair.BTC_USD - "BTC/EUR" -> to.bitkit.data.dto.price.TradingPair.BTC_EUR - "BTC/GBP" -> to.bitkit.data.dto.price.TradingPair.BTC_GBP - "BTC/JPY" -> to.bitkit.data.dto.price.TradingPair.BTC_JPY + "BTC/USD" -> TradingPair.BTC_USD + "BTC/EUR" -> TradingPair.BTC_EUR + "BTC/GBP" -> TradingPair.BTC_GBP + "BTC/JPY" -> TradingPair.BTC_JPY else -> null } - } ?: listOf(to.bitkit.data.dto.price.TradingPair.BTC_USD) + } ?: listOf(TradingPair.BTC_USD) val periodStr = priceJson["selectedPeriod"]?.jsonPrimitive?.content ?: "1D" val period = when (periodStr) { - "1D" -> to.bitkit.data.dto.price.GraphPeriod.ONE_DAY - "1W" -> to.bitkit.data.dto.price.GraphPeriod.ONE_WEEK - "1M" -> to.bitkit.data.dto.price.GraphPeriod.ONE_MONTH - "1Y" -> to.bitkit.data.dto.price.GraphPeriod.ONE_YEAR - else -> to.bitkit.data.dto.price.GraphPeriod.ONE_DAY + "1D" -> GraphPeriod.ONE_DAY + "1W" -> GraphPeriod.ONE_WEEK + "1M" -> GraphPeriod.ONE_MONTH + "1Y" -> GraphPeriod.ONE_YEAR + else -> GraphPeriod.ONE_DAY } val showSource = priceJson["showSource"]?.jsonPrimitive?.content ?.toBooleanStrictOrNull() ?: false widgetsStore.updatePricePreferences( - to.bitkit.models.widget.PricePreferences( + PricePreferences( enabledPairs = selectedPairs, period = period, showSource = showSource ) ) - } catch (e: Exception) { - Logger.error("Failed to migrate price preferences: $e", e, context = TAG) + }.onFailure { + Logger.error("Failed to migrate price preferences: $it", it, context = TAG) } } widgetOptions["weather"]?.let { weatherData -> - try { - val weatherJson = json.decodeFromString( + runCatching { + val weatherJson = json.decodeFromString( weatherData.decodeToString() ) val showTitle = weatherJson["showStatus"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: true @@ -960,21 +867,21 @@ class MigrationService @Inject constructor( ?.toBooleanStrictOrNull() ?: false widgetsStore.updateWeatherPreferences( - to.bitkit.models.widget.WeatherPreferences( + WeatherPreferences( showTitle = showTitle, showDescription = showDescription, showCurrentFee = showCurrentFee, showNextBlockFee = showNextBlockFee ) ) - } catch (e: Exception) { - Logger.error("Failed to migrate weather preferences: $e", e, context = TAG) + }.onFailure { + Logger.error("Failed to migrate weather preferences: $it", it, context = TAG) } } widgetOptions["news"]?.let { newsData -> - try { - val newsJson = json.decodeFromString( + runCatching { + val newsJson = json.decodeFromString( newsData.decodeToString() ) val showTime = newsJson["showDate"]?.jsonPrimitive?.content @@ -983,19 +890,19 @@ class MigrationService @Inject constructor( ?.toBooleanStrictOrNull() ?: true widgetsStore.updateHeadlinePreferences( - to.bitkit.models.widget.HeadlinePreferences( + HeadlinePreferences( showTime = showTime, showSource = showSource ) ) - } catch (e: Exception) { - Logger.error("Failed to migrate news preferences: $e", e, context = TAG) + }.onFailure { + Logger.error("Failed to migrate news preferences: $it", it, context = TAG) } } widgetOptions["blocks"]?.let { blocksData -> - try { - val blocksJson = json.decodeFromString( + runCatching { + val blocksJson = json.decodeFromString( blocksData.decodeToString() ) val showBlock = blocksJson["height"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: true @@ -1007,7 +914,7 @@ class MigrationService @Inject constructor( val showSource = blocksJson["showSource"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: false widgetsStore.updateBlocksPreferences( - to.bitkit.models.widget.BlocksPreferences( + BlocksPreferences( showBlock = showBlock, showTime = showTime, showDate = showDate, @@ -1016,25 +923,25 @@ class MigrationService @Inject constructor( showSource = showSource ) ) - } catch (e: Exception) { - Logger.error("Failed to migrate blocks preferences: $e", e, context = TAG) + }.onFailure { + Logger.error("Failed to migrate blocks preferences: $it", it, context = TAG) } } widgetOptions["facts"]?.let { factsData -> - try { - val factsJson = json.decodeFromString( + runCatching { + val factsJson = json.decodeFromString( factsData.decodeToString() ) val showSource = factsJson["showSource"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: false widgetsStore.updateFactsPreferences( - to.bitkit.models.widget.FactsPreferences( + FactsPreferences( showSource = showSource ) ) - } catch (e: Exception) { - Logger.error("Failed to migrate facts preferences: $e", e, context = TAG) + }.onFailure { + Logger.error("Failed to migrate facts preferences: $it", it, context = TAG) } } } @@ -1042,12 +949,12 @@ class MigrationService @Inject constructor( private suspend fun migrateMMKVData() { val mmkvData = loadRNMmkvData() ?: return - extractRNActivities(mmkvData)?.let { activities -> - applyRNActivities(activities) + extractRNActivities(mmkvData)?.let { + applyRNActivities(it) } - extractRNClosedChannels(mmkvData)?.let { channels -> - applyRNClosedChannels(channels) + extractRNClosedChannels(mmkvData)?.let { + applyRNClosedChannels(it) } extractRNSettings(mmkvData)?.let { settings -> @@ -1065,13 +972,35 @@ class MigrationService @Inject constructor( extractRNTodos(mmkvData)?.let { todos -> applyRNTodos(todos) } + + extractRNBlocktank(mmkvData)?.let { (orderIds, paidOrders) -> + applyRNBlocktank(orderIds, paidOrders) + } } - suspend fun hasRNRemoteBackup(): Boolean = runCatching { - rnBackupClient.hasBackup() - }.onFailure { e -> - Logger.error("Failed to check RN remote backup", e, context = TAG) - }.getOrDefault(false) + private suspend fun applyRNBlocktank(orderIds: List, paidOrders: Map) { + if (orderIds.isEmpty()) return + + paidOrders.forEach { (orderId, txId) -> + cacheStore.addPaidOrder(orderId, txId) + } + + runCatching { + val fetchedOrders = coreService.blocktank.orders( + orderIds = orderIds, + filter = null, + refresh = true, + ) + if (fetchedOrders.isNotEmpty()) { + coreService.blocktank.upsertOrderList(fetchedOrders) + if (paidOrders.isNotEmpty()) { + createTransfersForPaidOrders(paidOrders, fetchedOrders) + } + } + }.onFailure { e -> + Logger.warn("Failed to fetch and upsert local Blocktank orders: $e", context = TAG) + } + } suspend fun getRNRemoteBackupTimestamp(): ULong? = runCatching { rnBackupClient.getLatestBackupTimestamp() @@ -1206,7 +1135,7 @@ class MigrationService @Inject constructor( } } - private suspend fun applyRNRemoteMetadata(data: ByteArray) { + private fun applyRNRemoteMetadata(data: ByteArray) { runCatching { pendingRemoteMetadata = decodeBackupData(data) }.onFailure { e -> @@ -1214,7 +1143,7 @@ class MigrationService @Inject constructor( } } - private suspend fun applyRNRemoteWallet(data: ByteArray) { + private fun applyRNRemoteWallet(data: ByteArray) { runCatching { val backup = decodeBackupData(data) @@ -1257,32 +1186,54 @@ class MigrationService @Inject constructor( private suspend fun applyRNRemoteBlocktank(data: ByteArray) { runCatching { val backup = decodeBackupData(data) - val orderIds = mutableListOf() - backup.orders?.let { orders -> - orderIds.addAll(orders.map { it.id }) + backup.paidOrders?.let { paidOrders -> + paidOrders.forEach { (orderId, txId) -> + cacheStore.addPaidOrder(orderId, txId) + } + if (paidOrders.isNotEmpty()) { + pendingRemotePaidOrders = paidOrders + } } + }.onFailure { e -> + Logger.warn("Failed to decode RN remote blocktank backup: $e", context = TAG) + } + } - backup.paidOrders?.let { paidOrderIds -> - orderIds.addAll(paidOrderIds) + private suspend fun createTransfersForPaidOrders( + paidOrdersMap: Map, + orders: List, + ) { + val now = System.currentTimeMillis() / 1000 + val transfers = paidOrdersMap.mapNotNull { (orderId, txId) -> + val order = orders.find { it.id == orderId } + when { + order == null -> { + Logger.warn("Paid order $orderId not found in fetched orders", context = TAG) + null + } + order.state2 == com.synonym.bitkitcore.BtOrderState2.EXECUTED -> null + else -> TransferEntity( + id = txId, + type = TransferType.TO_SPENDING, + amountSats = (order.clientBalanceSat.safe() + order.feeSat.safe()).toLong(), + channelId = null, + fundingTxId = null, + lspOrderId = orderId, + isSettled = false, + createdAt = now, + settledAt = null, + ) } + } - if (orderIds.isNotEmpty()) { - runCatching { - val fetchedOrders = coreService.blocktank.orders( - orderIds = orderIds, - filter = null, - refresh = true, - ) - if (fetchedOrders.isNotEmpty()) { - coreService.blocktank.upsertOrderList(fetchedOrders) - } - }.onFailure { e -> - Logger.warn("Failed to fetch and upsert Blocktank orders: $e", context = TAG) - } + if (transfers.isNotEmpty()) { + runCatching { + transferDao.upsert(transfers) + Logger.info("Created ${transfers.size} transfers for paid Blocktank orders", context = TAG) + }.onFailure { e -> + Logger.error("Failed to create transfers for paid orders: $e", context = TAG) } - }.onFailure { e -> - Logger.warn("Failed to decode RN remote blocktank backup: $e", context = TAG) } } @@ -1329,6 +1280,31 @@ class MigrationService @Inject constructor( applyRNMetadata(metadata) pendingRemoteMetadata = null } + + pendingRemotePaidOrders?.let { paidOrders -> + applyRemotePaidOrders(paidOrders) + pendingRemotePaidOrders = null + } + } + + private suspend fun applyRemotePaidOrders(paidOrders: Map) { + if (paidOrders.isEmpty()) return + + val orderIds = paidOrders.keys.toList() + + runCatching { + val fetchedOrders = coreService.blocktank.orders( + orderIds = orderIds, + filter = null, + refresh = true, + ) + if (fetchedOrders.isNotEmpty()) { + coreService.blocktank.upsertOrderList(fetchedOrders) + createTransfersForPaidOrders(paidOrders, fetchedOrders) + } + }.onFailure { e -> + Logger.warn("Failed to fetch and process remote paid orders: $e", context = TAG) + } } private suspend fun applyRemoteTransfers(transfers: Map) { @@ -1358,7 +1334,7 @@ class MigrationService @Inject constructor( boostTxIds = updatedParentBoostTxIds, ) - var updatedNewOnchain = newOnchain.copy( + val updatedNewOnchain = newOnchain.copy( isBoosted = false, boostTxIds = newOnchain.boostTxIds.filter { it != oldTxId }, ) @@ -1429,7 +1405,7 @@ class MigrationService @Inject constructor( var wasUpdated = false if (item.timestamp > 0) { - val migratedTimestamp = (item.timestamp / MILLISECONDS_TO_SECONDS).toULong() + val migratedTimestamp = (item.timestamp / MS_PER_SEC).toULong() if (updated.timestamp != migratedTimestamp) { updated = updated.copy(timestamp = migratedTimestamp) wasUpdated = true @@ -1437,7 +1413,7 @@ class MigrationService @Inject constructor( } item.confirmTimestamp?.let { confirmTimestamp -> if (confirmTimestamp > 0) { - val migratedConfirmTimestamp = (confirmTimestamp / MILLISECONDS_TO_SECONDS).toULong() + val migratedConfirmTimestamp = (confirmTimestamp / MS_PER_SEC).toULong() if (updated.confirmTimestamp != migratedConfirmTimestamp) { updated = updated.copy(confirmTimestamp = migratedConfirmTimestamp) wasUpdated = true @@ -1512,15 +1488,15 @@ class MigrationService @Inject constructor( } } - @Suppress("LongMethod", "CyclomaticComplexMethod") + @Suppress("LongMethod", "CyclomaticComplexMethod", "NestedBlockDepth") private fun convertRNWidgetPreferences( - widgetsDict: kotlinx.serialization.json.JsonObject? + widgetsDict: JsonObject?, ): Map { val result = mutableMapOf() if (widgetsDict == null) return result fun getBool( - source: kotlinx.serialization.json.JsonObject, + source: JsonObject, key: String, fallbackKey: String? = null, defaultValue: Boolean = false, @@ -1528,15 +1504,16 @@ class MigrationService @Inject constructor( val keys = if (fallbackKey != null) listOf(key, fallbackKey) else listOf(key) for (k in keys) { source[k]?.let { element -> - when { - element is kotlinx.serialization.json.JsonPrimitive && element.isString -> { - val str = element.content.lowercase() - return str == "true" || str == "1" - } - element is kotlinx.serialization.json.JsonPrimitive -> { - return element.content.toBooleanStrictOrNull() - ?: defaultValue + when (element) { + is JsonPrimitive -> { + return if (element.isString) { + val str = element.content.lowercase() + str == "true" || str == "1" + } else { + element.content.toBooleanStrictOrNull() ?: defaultValue + } } + else -> continue } } @@ -1552,7 +1529,7 @@ class MigrationService @Inject constructor( val mappedPairs = pairsArray?.mapNotNull { pairElement -> pairElement.jsonPrimitive.content.replace("_", "/") } ?: emptyList() - val selectedPairs = if (mappedPairs.isNotEmpty()) mappedPairs else listOf("BTC/USD") + val selectedPairs = mappedPairs.ifEmpty { listOf("BTC/USD") } val rnPeriod = prefs["period"]?.jsonPrimitive?.content ?: "1D" val periodMap = mapOf( @@ -1624,7 +1601,25 @@ class MigrationService @Inject constructor( data class PendingChannelMigration( val channelManager: ByteArray, val channelMonitors: List, -) +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PendingChannelMigration + + if (!channelManager.contentEquals(other.channelManager)) return false + if (channelMonitors != other.channelMonitors) return false + + return true + } + + override fun hashCode(): Int { + var result = channelManager.contentHashCode() + result = 31 * result + channelMonitors.hashCode() + return result + } +} @Serializable data class RNSettings( @@ -1765,8 +1760,76 @@ data class RNWidgetsWithOptions( val widgetOptions: Map, ) +private val Context.rnMigrationDataStore: DataStore by preferencesDataStore("rn_migration") +private val Context.rnKeychainDataStore: DataStore by preferencesDataStore("RN_KEYCHAIN") + private enum class RNKeychainKey(val service: String) { MNEMONIC("wallet0"), PASSPHRASE("wallet0passphrase"), PIN("pin"), } + +@Serializable +private data class RNWalletState(val wallets: Map? = null) + +@Serializable +private data class RNWalletData( + val transfers: Map>? = null, + val boostedTransactions: Map>? = null, +) + +@Serializable +private data class RNRemoteActivityItem( + val id: String, + val activityType: String, + val txType: String, + val txId: String? = null, + val value: Long, + val fee: Long? = null, + val feeRate: Long? = null, + val address: String? = null, + val confirmed: Boolean? = null, + val timestamp: Long, + val isBoosted: Boolean? = null, + val isTransfer: Boolean? = null, + val exists: Boolean? = null, + val confirmTimestamp: Long? = null, + val channelId: String? = null, + val transferTxId: String? = null, + val status: String? = null, + val message: String? = null, + val preimage: String? = null, + val boostedParents: List? = null, +) + +@Serializable +private data class RNRemoteWalletBackup( + val transfers: Map>? = null, + val boostedTransactions: Map>? = null, +) + +@Serializable +private data class RNRemoteTransfer(val txId: String? = null, val type: String? = null) + +@Serializable +private data class RNRemoteBoostedTx( + val oldTxId: String? = null, + val newTxId: String? = null, + val childTransaction: String? = null, +) + +@Serializable +private data class RNRemoteBlocktankBackup( + val orders: List? = null, + val paidOrders: Map? = null, +) + +@Serializable +private data class RNRemoteBlocktankOrder( + val id: String, + val state: String? = null, + val lspBalanceSat: ULong? = null, + val clientBalanceSat: ULong? = null, + val channelExpiryWeeks: Int? = null, + val createdAt: String? = null, +) diff --git a/app/src/main/java/to/bitkit/services/RNBackupClient.kt b/app/src/main/java/to/bitkit/services/RNBackupClient.kt index fc1062e6c..e2d87742b 100644 --- a/app/src/main/java/to/bitkit/services/RNBackupClient.kt +++ b/app/src/main/java/to/bitkit/services/RNBackupClient.kt @@ -24,12 +24,12 @@ import org.ldk.structs.KeysManager import org.lightningdevkit.ldknode.Network import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher -import to.bitkit.di.json import to.bitkit.env.Env import to.bitkit.utils.AppError import to.bitkit.utils.Crypto import to.bitkit.utils.Logger import javax.crypto.Cipher +import javax.crypto.Mac import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec import javax.inject.Inject @@ -43,12 +43,15 @@ class RNBackupClient @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val json: Json, ) { + @Suppress("SpellCheckingInspection") companion object { private const val TAG = "RNBackup" private const val VERSION = "v1" private const val SIGNED_MESSAGE_PREFIX = "react-native-ldk backup server auth:" private const val GCM_IV_LENGTH = 12 private const val GCM_TAG_LENGTH = 16 + private const val PBKDF2_ITERATIONS = 2048 + private const val PBKDF2_KEY_LENGTH_BITS = 512 } @Volatile @@ -58,8 +61,7 @@ class RNBackupClient @Inject constructor( suspend fun listFiles(fileGroup: String? = "ldk"): RNBackupListResponse? = withContext(ioDispatcher) { runCatching { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw RNBackupError.NotSetup + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw RNBackupError.NotSetup() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) val bearer = authenticate(mnemonic, passphrase) @@ -68,9 +70,7 @@ class RNBackupClient @Inject constructor( header("Authorization", bearer.bearer) } - if (!response.status.isSuccess()) { - throw RNBackupError.RequestFailed("Status: ${response.status.value}") - } + if (!response.status.isSuccess()) throw RNBackupError.RequestFailed("Status: ${response.status.value}") response.body() }.onFailure { e -> @@ -80,8 +80,7 @@ class RNBackupClient @Inject constructor( suspend fun retrieve(label: String, fileGroup: String? = null): ByteArray? = withContext(ioDispatcher) { runCatching { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw RNBackupError.NotSetup + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw RNBackupError.NotSetup() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) val bearer = authenticate(mnemonic, passphrase) @@ -90,14 +89,13 @@ class RNBackupClient @Inject constructor( header("Authorization", bearer.bearer) } - if (!response.status.isSuccess()) { - throw RNBackupError.RequestFailed("Status: ${response.status.value}") - } + if (!response.status.isSuccess()) throw RNBackupError.RequestFailed("Status: ${response.status.value}") val encryptedData = response.body() if (encryptedData.isEmpty()) throw RNBackupError.RequestFailed("Retrieved data is empty") val encryptionKey = deriveEncryptionKey(mnemonic, passphrase) + decrypt(encryptedData, encryptionKey).also { if (it.isEmpty()) throw RNBackupError.DecryptFailed("Decrypted data is empty") } @@ -108,8 +106,7 @@ class RNBackupClient @Inject constructor( suspend fun retrieveChannelMonitor(channelId: String): ByteArray? = withContext(ioDispatcher) { runCatching { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw RNBackupError.NotSetup + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw RNBackupError.NotSetup() val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) val bearer = authenticate(mnemonic, passphrase) @@ -124,9 +121,7 @@ class RNBackupClient @Inject constructor( header("Authorization", bearer.bearer) } - if (!response.status.isSuccess()) { - throw RNBackupError.RequestFailed("Status: ${response.status.value}") - } + if (!response.status.isSuccess()) throw RNBackupError.RequestFailed("Status: ${response.status.value}") val encryptedData = response.body() if (encryptedData.isEmpty()) throw RNBackupError.RequestFailed("Retrieved data is empty") @@ -140,18 +135,6 @@ class RNBackupClient @Inject constructor( }.getOrNull() } - suspend fun hasBackup(): Boolean = withContext(ioDispatcher) { - runCatching { - val ldkFiles = listFiles(fileGroup = "ldk") - val bitkitFiles = listFiles(fileGroup = "bitkit") - val hasLdkFiles = !ldkFiles?.list.isNullOrEmpty() || !ldkFiles?.channelMonitors.isNullOrEmpty() - val hasBitkitFiles = !bitkitFiles?.list.isNullOrEmpty() - hasLdkFiles || hasBitkitFiles - }.onFailure { e -> - Logger.error("Failed to check if backup exists", e, context = TAG) - }.getOrDefault(false) - } - suspend fun getLatestBackupTimestamp(): ULong? = withContext(ioDispatcher) { runCatching { val bitkitFiles = listFiles(fileGroup = "bitkit")?.list ?: return@withContext null @@ -174,15 +157,16 @@ class RNBackupClient @Inject constructor( var latestTimestamp: ULong? = null for (label in labels) { - if ("$label.bin" !in bitkitFiles) continue - - val data = retrieve(label, fileGroup = "bitkit") ?: continue - val timestamp = runCatching { - json.decodeFromString(String(data)).metadata?.timestamp - }.getOrNull() ?: continue - - val ts = (timestamp / 1000).toULong() - latestTimestamp = maxOf(latestTimestamp ?: 0uL, ts) + if ("$label.bin" in bitkitFiles) { + retrieve(label, fileGroup = "bitkit")?.let { data -> + runCatching { + json.decodeFromString(String(data)).metadata?.timestamp + }.getOrNull()?.let { timestamp -> + val ts = (timestamp / 1000).toULong() + latestTimestamp = maxOf(latestTimestamp ?: 0uL, ts) + } + } + } } latestTimestamp }.onFailure { e -> @@ -235,9 +219,7 @@ class RNBackupClient @Inject constructor( setBody(challengeBody) } - if (!challengeResponse.status.isSuccess()) { - throw RNBackupError.AuthFailed - } + if (!challengeResponse.status.isSuccess()) throw RNBackupError.AuthFailed() val challengeResult = challengeResponse.body() val authBody = json.encodeToString( @@ -253,9 +235,7 @@ class RNBackupClient @Inject constructor( setBody(authBody) } - if (!authResponse.status.isSuccess()) { - throw RNBackupError.AuthFailed - } + if (!authResponse.status.isSuccess()) throw RNBackupError.AuthFailed() authResponse.body().also { cachedBearer = it } } @@ -283,12 +263,7 @@ class RNBackupClient @Inject constructor( return runCatching { val keysManager = KeysManager.of(bip32Seed, seconds, nanoSeconds) - val method = keysManager.javaClass.getMethod("get_node_secret_key") - when (val nodeSecretKey = method.invoke(keysManager)) { - is ByteArray -> nodeSecretKey - is List<*> -> nodeSecretKey.map { (it as UByte).toByte() }.toByteArray() - else -> throw ClassCastException("Unexpected type: ${nodeSecretKey?.javaClass?.name}") - } + keysManager._node_secret_key }.getOrElse { bip32Seed } } @@ -302,22 +277,22 @@ class RNBackupClient @Inject constructor( val mnemonicBytes = mnemonic.toByteArray(Charsets.UTF_8) val salt = ("mnemonic" + (passphrase ?: "")).toByteArray(Charsets.UTF_8) val generator = PKCS5S2ParametersGenerator(SHA512Digest()) - generator.init(mnemonicBytes, salt, 2048) - return (generator.generateDerivedParameters(512) as KeyParameter).key + generator.init(mnemonicBytes, salt, PBKDF2_ITERATIONS) + + return (generator.generateDerivedParameters(PBKDF2_KEY_LENGTH_BITS) as KeyParameter).key } private fun deriveMasterKey(seed: ByteArray): ByteArray { - val hmac = javax.crypto.Mac.getInstance("HmacSHA512") - val keySpec = javax.crypto.spec.SecretKeySpec("Bitcoin seed".toByteArray(), "HmacSHA512") + val algorithm = "HmacSHA512" + val hmac = Mac.getInstance(algorithm) + val keySpec = SecretKeySpec("Bitcoin seed".toByteArray(), algorithm) hmac.init(keySpec) val i = hmac.doFinal(seed) return i.sliceArray(0 until 32) } private fun decrypt(blob: ByteArray, encryptionKey: ByteArray): ByteArray { - if (blob.size < GCM_IV_LENGTH + GCM_TAG_LENGTH) { - throw RNBackupError.DecryptFailed("Data too short") - } + if (blob.size < GCM_IV_LENGTH + GCM_TAG_LENGTH) throw RNBackupError.DecryptFailed("Data too short") val nonce = blob.sliceArray(0 until GCM_IV_LENGTH) val tag = blob.sliceArray(blob.size - GCM_TAG_LENGTH until blob.size) @@ -332,9 +307,7 @@ class RNBackupClient @Inject constructor( return cipher.doFinal(ciphertext + tag) } - private fun ByteArray.toHex(): String { - return this.joinToString("") { "%02x".format(it) } - } + private fun ByteArray.toHex(): String = this.joinToString("") { "%02x".format(it) } } @Serializable @@ -355,8 +328,8 @@ data class RNBackupListResponse( ) sealed class RNBackupError(message: String) : AppError(message) { - data object NotSetup : RNBackupError("RN backup client not setup") - data object AuthFailed : RNBackupError("Authentication failed") - data class RequestFailed(override val message: String) : RNBackupError(message) - data class DecryptFailed(override val message: String) : RNBackupError(message) + class NotSetup : RNBackupError("RN backup client not setup") + class AuthFailed : RNBackupError("Authentication failed") + class RequestFailed(override val message: String) : RNBackupError(message) + class DecryptFailed(override val message: String) : RNBackupError(message) }