diff --git a/.github/workflows/test-android.yaml b/.github/workflows/test-android.yaml index 5b335b3c..23b71358 100644 --- a/.github/workflows/test-android.yaml +++ b/.github/workflows/test-android.yaml @@ -83,7 +83,7 @@ jobs: - name: "Run Android connected tests" uses: reactivecircus/android-emulator-runner@v2 with: - api-level: 30 + api-level: 34 target: google_apis arch: x86_64 profile: Nexus 6 diff --git a/bdk-android/lib/src/androidTest/assets/README.md b/bdk-android/lib/src/androidTest/assets/README.md index 9d9331b7..60ce89e9 100644 --- a/bdk-android/lib/src/androidTest/assets/README.md +++ b/bdk-android/lib/src/androidTest/assets/README.md @@ -4,6 +4,7 @@ The wallet database `awesome_wallet_1.sqlite3` is used for testing. This wallet: +- Was created using bdk_wallet 2.X - Is a Regtest wallet - Was built using 2 descriptors - Has a transaction on address index 0 @@ -31,3 +32,12 @@ The wallet database `wallet_pre_v1.sqlite3` is used for testing. This wallet: - Has revealed the first 8 addresses on the external keychain (last revealed index is 7) - Has revealed the first address on the internal keychain (last revealed index is 0) - The descriptors are BIP86 descriptors with the MNEMONIC_AWESOME mnemonic + +## Old Databases + +The `old_databases` directory contains wallets created from different versions of BDK. These wallets: + +- Are Regtest wallets +- Have revealed 7 addresses (0-6) on the external keychain (next address to come up is index 7) +- Have revealed the first address (index 0) on the internal keychain (next address to come up is index 1) +- The wallets were created with BIP84 descriptors with the MNEMONIC_ALL mnemonic diff --git a/bdk-android/lib/src/androidTest/assets/db_v3.sqlite3 b/bdk-android/lib/src/androidTest/assets/db_v3.sqlite3 new file mode 100644 index 00000000..6f3d40de Binary files /dev/null and b/bdk-android/lib/src/androidTest/assets/db_v3.sqlite3 differ diff --git a/bdk-android/lib/src/androidTest/assets/old_databases/db_v032.sqlite3 b/bdk-android/lib/src/androidTest/assets/old_databases/db_v032.sqlite3 new file mode 100644 index 00000000..c8ecf67c Binary files /dev/null and b/bdk-android/lib/src/androidTest/assets/old_databases/db_v032.sqlite3 differ diff --git a/bdk-android/lib/src/androidTest/assets/old_databases/db_v1.sqlite3 b/bdk-android/lib/src/androidTest/assets/old_databases/db_v1.sqlite3 new file mode 100644 index 00000000..b4a7be87 Binary files /dev/null and b/bdk-android/lib/src/androidTest/assets/old_databases/db_v1.sqlite3 differ diff --git a/bdk-android/lib/src/androidTest/assets/old_databases/db_v2.sqlite3 b/bdk-android/lib/src/androidTest/assets/old_databases/db_v2.sqlite3 new file mode 100644 index 00000000..bc561804 Binary files /dev/null and b/bdk-android/lib/src/androidTest/assets/old_databases/db_v2.sqlite3 differ diff --git a/bdk-android/lib/src/androidTest/kotlin/org/bitcoindevkit/DatabaseVersionCompatTest.kt b/bdk-android/lib/src/androidTest/kotlin/org/bitcoindevkit/DatabaseVersionCompatTest.kt new file mode 100644 index 00000000..4e7c1665 --- /dev/null +++ b/bdk-android/lib/src/androidTest/kotlin/org/bitcoindevkit/DatabaseVersionCompatTest.kt @@ -0,0 +1,228 @@ +package org.bitcoindevkit + +import android.database.sqlite.SQLiteDatabase +import androidx.test.platform.app.InstrumentationRegistry +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +const val MNEMONIC_ALL = "all all all all all all all all all all all all" + +class DatabaseVersionCompatTest { + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + private fun resolveAsset(dbFileName: String, subDir: String? = null): String { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val assetPath = if (subDir != null) "$subDir/$dbFileName" else dbFileName + val destFile = File(context.getDatabasePath(dbFileName).path) + context.assets.open(assetPath).use { input -> + destFile.outputStream().use { output -> + input.copyTo(output) + } + } + return destFile.absolutePath + } + + val descriptorSecretKey: DescriptorSecretKey = DescriptorSecretKey( + NetworkKind.TEST, + Mnemonic.fromString(MNEMONIC_ALL), + null + ) + val descriptor: Descriptor = Descriptor.newBip84(descriptorSecretKey, KeychainKind.EXTERNAL, NetworkKind.TEST) + val changeDescriptor: Descriptor = Descriptor.newBip84(descriptorSecretKey, KeychainKind.INTERNAL, NetworkKind.TEST) + + // You can take a v1 Wallet database and use it to create a v3 Wallet. + @Test + fun loadV1WalletDBIntoV3Wallet() { + val dbV1 = Persister.newSqlite(resolveAsset("db_v1.sqlite3", "old_databases")) + + val wallet = Wallet.load( + descriptor = descriptor, + changeDescriptor = changeDescriptor, + persister = dbV1, + ) + + val addressInfo: AddressInfo = wallet.revealNextAddress(KeychainKind.EXTERNAL) + val changeAddressInfo: AddressInfo = wallet.revealNextAddress(KeychainKind.INTERNAL) + println("Address info: $addressInfo") + println("Change address info: $changeAddressInfo") + + assertEquals(addressInfo.index, 7u) + assertEquals(changeAddressInfo.index, 1u) + } + + // You can take a v2 Wallet database and use it to create a v3 Wallet. + @Test + fun loadV2WalletDBIntoV3Wallet() { + val dbV2 = Persister.newSqlite(resolveAsset("db_v2.sqlite3", "old_databases")) + + val wallet = Wallet.load( + descriptor = descriptor, + changeDescriptor = changeDescriptor, + persister = dbV2, + ) + + val addressInfo: AddressInfo = wallet.revealNextAddress(KeychainKind.EXTERNAL) + val changeAddressInfo: AddressInfo = wallet.revealNextAddress(KeychainKind.INTERNAL) + // println("Address info: $addressInfo") + // println("Change address info: $changeAddressInfo") + + assertEquals(addressInfo.index, 7u) + assertEquals(changeAddressInfo.index, 1u) + } + + // You can take a v1 Wallet database and use it to create a v3 Wallet, and the database migrates gracefully. + // The v3 database adds the bdk_descriptor_derived_spks and bdk_wallet_locked_outpoints tables. + @Test + fun v3WalletWillAddRequiredFieldsToV1DB() { + val dbPath = resolveAsset("db_v1.sqlite3", "old_databases") + val oldV1DB = Persister.newSqlite(dbPath) + + val tablesBefore: List = SQLiteDatabase.openDatabase(dbPath, null, SQLiteDatabase.OPEN_READONLY).use { db -> + db.rawQuery("SELECT name FROM sqlite_master WHERE type='table'", null).use { cursor -> + generateSequence { if (cursor.moveToNext()) cursor.getString(0) else null }.toList() + } + } + // println("V1 Database Tables: $tablesBefore") + // V1 Database Tables: [bdk_schemas, bdk_wallet, bdk_blocks, bdk_txs, bdk_txouts, bdk_anchors, bdk_descriptor_last_revealed] + + assertTrue(!tablesBefore.contains("bdk_descriptor_derived_spks")) + assertTrue(!tablesBefore.contains("bdk_wallet_locked_outpoints")) + + val wallet1 = Wallet.load( + descriptor = descriptor, + changeDescriptor = changeDescriptor, + persister = oldV1DB, + ) + + val addressInfoWallet1: AddressInfo = wallet1.revealNextAddress(KeychainKind.EXTERNAL) + val changeAddressInfoWallet1: AddressInfo = wallet1.revealNextAddress(KeychainKind.INTERNAL) + + assertEquals(addressInfoWallet1.index, 7u) + assertEquals(changeAddressInfoWallet1.index, 1u) + + wallet1.persist(oldV1DB) + + val tables: List = SQLiteDatabase.openDatabase(dbPath, null, SQLiteDatabase.OPEN_READONLY).use { db -> + db.rawQuery("SELECT name FROM sqlite_master WHERE type='table'", null).use { cursor -> + generateSequence { if (cursor.moveToNext()) cursor.getString(0) else null }.toList() + } + } + + // println("V3 Database Tables: $tables") + // V3 Database Tables: [bdk_schemas, bdk_wallet, bdk_blocks, bdk_txs, bdk_txouts, bdk_anchors, bdk_descriptor_last_revealed, bdk_wallet_locked_outpoints, bdk_descriptor_derived_spks] + + assertTrue(tables.contains("bdk_descriptor_derived_spks")) + assertTrue(tables.contains("bdk_wallet_locked_outpoints")) + } + + // You can take a v2 Wallet database and use it to create a v3 Wallet, and the database migrates gracefully. + // The v3 database adds the bdk_wallet_locked_outpoints table. + @Test + fun v3WalletWillAddRequiredFieldsToV2DB() { + val dbPath = resolveAsset("db_v2.sqlite3", "old_databases") + val oldV1DB = Persister.newSqlite(dbPath) + + val tablesBefore: List = SQLiteDatabase.openDatabase(dbPath, null, SQLiteDatabase.OPEN_READONLY).use { db -> + db.rawQuery("SELECT name FROM sqlite_master WHERE type='table'", null).use { cursor -> + generateSequence { if (cursor.moveToNext()) cursor.getString(0) else null }.toList() + } + } + println("V2 Database Tables: $tablesBefore") + // V2 Database Tables: [bdk_schemas, bdk_wallet, bdk_blocks, bdk_txs, bdk_txouts, bdk_anchors, bdk_descriptor_last_revealed, bdk_descriptor_derived_spks] + assertTrue(!tablesBefore.contains("bdk_wallet_locked_outpoints")) + + val wallet1 = Wallet.load( + descriptor = descriptor, + changeDescriptor = changeDescriptor, + persister = oldV1DB, + ) + + val addressInfoWallet1: AddressInfo = wallet1.revealNextAddress(KeychainKind.EXTERNAL) + val changeAddressInfoWallet1: AddressInfo = wallet1.revealNextAddress(KeychainKind.INTERNAL) + + assertEquals(addressInfoWallet1.index, 7u) + assertEquals(changeAddressInfoWallet1.index, 1u) + + wallet1.persist(oldV1DB) + + val tables: List = SQLiteDatabase.openDatabase(dbPath, null, SQLiteDatabase.OPEN_READONLY).use { db -> + db.rawQuery("SELECT name FROM sqlite_master WHERE type='table'", null).use { cursor -> + generateSequence { if (cursor.moveToNext()) cursor.getString(0) else null }.toList() + } + } + + println("V3 Database Tables: $tables") + // V3 Database Tables: [bdk_schemas, bdk_wallet, bdk_blocks, bdk_txs, bdk_txouts, bdk_anchors, bdk_descriptor_last_revealed, bdk_wallet_locked_outpoints, bdk_descriptor_derived_spks] + + assertTrue(tables.contains("bdk_wallet_locked_outpoints")) + } + + // The v3 wallet can load a v3 database + @Test + fun loadV3Wallet() { + val v3DB = Persister.newSqlite(resolveAsset("db_v3.sqlite3")) + + val wallet = Wallet.load( + descriptor = descriptor, + changeDescriptor = changeDescriptor, + persister = v3DB, + ) + + val addressInfo: AddressInfo = wallet.revealNextAddress(KeychainKind.EXTERNAL) + val changeAddressInfo: AddressInfo = wallet.revealNextAddress(KeychainKind.INTERNAL) + println("Address info: $addressInfo") + println("Change address info: $changeAddressInfo") + + assertEquals(addressInfo.index, 7u) + assertEquals(changeAddressInfo.index, 1u) + } + + // You can correctly migrate a v0.32 Wallet into a v3 Wallet + @Test + fun migrateToV3From032() { + val oldDB = Persister.newSqlite(resolveAsset("db_v032.sqlite3", "old_databases")) + val preV1Keychains: List = oldDB.getPreV1WalletKeychains() + + val externalPreV1Keychain = preV1Keychains.single { it.keychain == KeychainKind.EXTERNAL } + val internalPreV1Keychain = preV1Keychains.single { it.keychain == KeychainKind.INTERNAL } + + assertEquals(2, preV1Keychains.size) + assertEquals(KeychainKind.EXTERNAL, externalPreV1Keychain.keychain) + assertEquals(KeychainKind.INTERNAL, internalPreV1Keychain.keychain) + assertEquals(6u, externalPreV1Keychain.lastDerivationIndex) + assertEquals(0u, internalPreV1Keychain.lastDerivationIndex) + assertEquals("rn0zejch", externalPreV1Keychain.checksum) + assertEquals("j82ry8g0", internalPreV1Keychain.checksum) + + val newV3DBFilePath = context.getDatabasePath("new_v3_wallet.sqlite3") + newV3DBFilePath.parentFile?.mkdirs() + // This ensures local tests always create a new DB and don't reuse an old one leftover from prior tests + if (newV3DBFilePath.exists()) newV3DBFilePath.delete() + val newV3DB = Persister.newSqlite(newV3DBFilePath.absolutePath) + + val wallet = Wallet( + descriptor = descriptor, + changeDescriptor = changeDescriptor, + network = Network.REGTEST, + persister = newV3DB, + ) + + wallet.revealAddressesTo(KeychainKind.EXTERNAL, externalPreV1Keychain.lastDerivationIndex) + wallet.revealAddressesTo(KeychainKind.INTERNAL, internalPreV1Keychain.lastDerivationIndex) + wallet.persist(newV3DB) + + val reloadedWallet = Wallet.load( + descriptor = descriptor, + changeDescriptor = changeDescriptor, + persister = newV3DB, + ) + + val addressInfo: AddressInfo = reloadedWallet.revealNextAddress(KeychainKind.EXTERNAL) + val changeAddressInfo: AddressInfo = reloadedWallet.revealNextAddress(KeychainKind.INTERNAL) + + assertEquals(addressInfo.index, 7u) + assertEquals(changeAddressInfo.index, 1u) + } +}