diff --git a/CHANGELOG.md b/CHANGELOG.md index 57a37da5..cbe7971f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ and this library adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- `PersistableWallet` API provides a new `endpoint` parameter of type `LightWalletEndpoint`, which could be used for + the Lightwalletd server customization. The new parameter is part of PersistableWallet persistence. The SDK handles + the persistence migration internally. + ## [2.0.0] - 2023-09-25 ## [2.0.0-rc.4] - 2023-09-22 diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/home/viewmodel/WalletViewModel.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/home/viewmodel/WalletViewModel.kt index 54376f75..ef08727f 100644 --- a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/home/viewmodel/WalletViewModel.kt +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/home/viewmodel/WalletViewModel.kt @@ -25,8 +25,10 @@ import cash.z.ecc.android.sdk.model.WalletBalance import cash.z.ecc.android.sdk.model.Zatoshi import cash.z.ecc.android.sdk.model.ZcashNetwork import cash.z.ecc.android.sdk.model.ZecSend +import cash.z.ecc.android.sdk.model.defaultForNetwork import cash.z.ecc.android.sdk.model.send import cash.z.ecc.android.sdk.tool.DerivationTool +import co.electriccoin.lightwallet.client.model.LightWalletEndpoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.awaitClose @@ -143,10 +145,12 @@ class WalletViewModel(application: Application) : AndroidViewModel(application) val application = getApplication() viewModelScope.launch { + val network = ZcashNetwork.fromResources(application) val newWallet = PersistableWallet.new( - application, - ZcashNetwork.fromResources(application), - WalletInitMode.NewWallet + application = application, + zcashNetwork = network, + endpoint = LightWalletEndpoint.defaultForNetwork(network), + walletInitMode = WalletInitMode.NewWallet ) persistWallet(newWallet) } diff --git a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/seed/view/ConfigureSeedView.kt b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/seed/view/ConfigureSeedView.kt index 9341579e..d6f6e7ed 100644 --- a/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/seed/view/ConfigureSeedView.kt +++ b/demo-app/src/main/java/cash/z/ecc/android/sdk/demoapp/ui/screen/seed/view/ConfigureSeedView.kt @@ -21,6 +21,8 @@ import cash.z.ecc.android.sdk.fixture.WalletFixture import cash.z.ecc.android.sdk.model.PersistableWallet import cash.z.ecc.android.sdk.model.SeedPhrase import cash.z.ecc.android.sdk.model.ZcashNetwork +import cash.z.ecc.android.sdk.model.defaultForNetwork +import co.electriccoin.lightwallet.client.model.LightWalletEndpoint @Preview(name = "Seed") @Composable @@ -75,10 +77,11 @@ private fun ConfigureSeedMainContent( Button( onClick = { val newWallet = PersistableWallet( - zcashNetwork, - WalletFixture.Alice.getBirthday(zcashNetwork), - SeedPhrase.new(WalletFixture.Alice.seedPhrase), - WalletInitMode.RestoreWallet + network = zcashNetwork, + endpoint = LightWalletEndpoint.defaultForNetwork(zcashNetwork), + birthday = WalletFixture.Alice.getBirthday(zcashNetwork), + seedPhrase = SeedPhrase.new(WalletFixture.Alice.seedPhrase), + walletInitMode = WalletInitMode.RestoreWallet ) onExistingWallet(newWallet) } @@ -88,10 +91,11 @@ private fun ConfigureSeedMainContent( Button( onClick = { val newWallet = PersistableWallet( - zcashNetwork, - WalletFixture.Ben.getBirthday(zcashNetwork), - SeedPhrase.new(WalletFixture.Ben.seedPhrase), - WalletInitMode.RestoreWallet + network = zcashNetwork, + endpoint = LightWalletEndpoint.defaultForNetwork(zcashNetwork), + birthday = WalletFixture.Ben.getBirthday(zcashNetwork), + seedPhrase = SeedPhrase.new(WalletFixture.Ben.seedPhrase), + walletInitMode = WalletInitMode.RestoreWallet ) onExistingWallet(newWallet) } diff --git a/sdk-incubator-lib/src/androidTest/kotlin/cash/z/ecc/android/sdk/fixture/PersistableWalletFixture.kt b/sdk-incubator-lib/src/androidTest/kotlin/cash/z/ecc/android/sdk/fixture/PersistableWalletFixture.kt index c3351529..ba1b39e6 100644 --- a/sdk-incubator-lib/src/androidTest/kotlin/cash/z/ecc/android/sdk/fixture/PersistableWalletFixture.kt +++ b/sdk-incubator-lib/src/androidTest/kotlin/cash/z/ecc/android/sdk/fixture/PersistableWalletFixture.kt @@ -4,12 +4,16 @@ import cash.z.ecc.android.sdk.WalletInitMode import cash.z.ecc.android.sdk.model.BlockHeight import cash.z.ecc.android.sdk.model.PersistableWallet import cash.z.ecc.android.sdk.model.SeedPhrase +import cash.z.ecc.android.sdk.model.Testnet import cash.z.ecc.android.sdk.model.ZcashNetwork +import co.electriccoin.lightwallet.client.model.LightWalletEndpoint object PersistableWalletFixture { val NETWORK = ZcashNetwork.Testnet + val ENDPOINT = LightWalletEndpoint.Testnet + // These came from the mainnet 1500000.json file @Suppress("MagicNumber") val BIRTHDAY = BlockHeight.new(ZcashNetwork.Mainnet, 1500000L) @@ -20,8 +24,17 @@ object PersistableWalletFixture { fun new( network: ZcashNetwork = NETWORK, + endpoint: LightWalletEndpoint = ENDPOINT, birthday: BlockHeight = BIRTHDAY, seedPhrase: SeedPhrase = SEED_PHRASE, walletInitMode: WalletInitMode = WALLET_INIT_MODE - ) = PersistableWallet(network, birthday, seedPhrase, walletInitMode) + ) = PersistableWallet(network, endpoint, birthday, seedPhrase, walletInitMode) + + fun persistVersionOne() = PersistableWallet.toCustomJson( + version = PersistableWallet.VERSION_1, + network = NETWORK, + endpoint = null, + birthday = BIRTHDAY, + seed = SEED_PHRASE + ) } diff --git a/sdk-incubator-lib/src/androidTest/kotlin/cash/z/ecc/android/sdk/model/PersistableWalletTest.kt b/sdk-incubator-lib/src/androidTest/kotlin/cash/z/ecc/android/sdk/model/PersistableWalletTest.kt index 39ab60f1..b6329dac 100644 --- a/sdk-incubator-lib/src/androidTest/kotlin/cash/z/ecc/android/sdk/model/PersistableWalletTest.kt +++ b/sdk-incubator-lib/src/androidTest/kotlin/cash/z/ecc/android/sdk/model/PersistableWalletTest.kt @@ -4,10 +4,11 @@ import androidx.test.filters.SmallTest import cash.z.ecc.android.sdk.count import cash.z.ecc.android.sdk.fixture.PersistableWalletFixture import cash.z.ecc.android.sdk.fixture.SeedPhraseFixture -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test +import co.electriccoin.lightwallet.client.model.LightWalletEndpoint +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue class PersistableWalletTest { @Test @@ -16,18 +17,30 @@ class PersistableWalletTest { val persistableWallet = PersistableWalletFixture.new() val jsonObject = persistableWallet.toJson() - assertEquals(4, jsonObject.keys().count()) + assertEquals(7, jsonObject.keys().count()) assertTrue(jsonObject.has(PersistableWallet.KEY_VERSION)) assertTrue(jsonObject.has(PersistableWallet.KEY_NETWORK_ID)) + assertTrue(jsonObject.has(PersistableWallet.KEY_ENDPOINT_HOST)) + assertTrue(jsonObject.has(PersistableWallet.KEY_ENDPOINT_PORT)) + assertTrue(jsonObject.has(PersistableWallet.KEY_ENDPOINT_IS_SECURE)) assertTrue(jsonObject.has(PersistableWallet.KEY_SEED_PHRASE)) assertTrue(jsonObject.has(PersistableWallet.KEY_BIRTHDAY)) - assertEquals(1, jsonObject.getInt(PersistableWallet.KEY_VERSION)) + assertEquals(PersistableWallet.VERSION_2, jsonObject.getInt(PersistableWallet.KEY_VERSION)) assertEquals(ZcashNetwork.Testnet.id, jsonObject.getInt(PersistableWallet.KEY_NETWORK_ID)) assertEquals( PersistableWalletFixture.SEED_PHRASE.joinToString(), jsonObject.getString(PersistableWallet.KEY_SEED_PHRASE) ) + assertEquals(PersistableWalletFixture.ENDPOINT.host, jsonObject.getString(PersistableWallet.KEY_ENDPOINT_HOST)) + assertEquals(PersistableWalletFixture.ENDPOINT.port, jsonObject.getInt(PersistableWallet.KEY_ENDPOINT_PORT)) + assertEquals( + PersistableWalletFixture.ENDPOINT.isSecure, + jsonObject.getBoolean( + PersistableWallet + .KEY_ENDPOINT_IS_SECURE + ) + ) // Birthday serialization is tested in a separate file } @@ -50,4 +63,69 @@ class PersistableWalletTest { assertFalse(actual.contains(SeedPhraseFixture.SEED_PHRASE)) } + + @Test + @SmallTest + fun get_seed_phrase_test() { + val json = PersistableWalletFixture.new().toJson() + assertEquals( + PersistableWalletFixture.SEED_PHRASE.joinToString(), + PersistableWallet.getSeedPhrase(json) + ) + } + + @Test + @SmallTest + fun get_birthday_test() { + val json = PersistableWalletFixture.new().toJson() + assertEquals( + PersistableWalletFixture.SEED_PHRASE.joinToString(), + PersistableWallet.getSeedPhrase(json) + ) + } + + @Test + @SmallTest + fun get_network_test() { + val json = PersistableWalletFixture.new().toJson() + assertEquals( + PersistableWalletFixture.BIRTHDAY.value, + PersistableWallet.getBirthday(json, PersistableWalletFixture.NETWORK)!!.value + ) + } + + @Test + @SmallTest + fun get_endpoint_test() { + val json = PersistableWalletFixture.new().toJson() + assertEquals( + PersistableWalletFixture.ENDPOINT.host, + PersistableWallet.getEndpoint(json).host + ) + assertEquals( + PersistableWalletFixture.ENDPOINT.port, + PersistableWallet.getEndpoint(json).port + ) + assertEquals( + PersistableWalletFixture.ENDPOINT.isSecure, + PersistableWallet.getEndpoint(json).isSecure + ) + } + + @Test + @SmallTest + fun version_1_2_migration_test() { + val json = PersistableWalletFixture.persistVersionOne() + assertEquals( + PersistableWallet.VERSION_1, + PersistableWallet.getVersion(json) + ) + + // Wallet version one deserialized by code supporting version two + val persistableWallet = PersistableWallet.from(json) + assertEquals( + LightWalletEndpoint.defaultForNetwork(persistableWallet.network), + persistableWallet.endpoint + ) + } } diff --git a/sdk-incubator-lib/src/main/java/cash/z/ecc/android/sdk/WalletCoordinator.kt b/sdk-incubator-lib/src/main/java/cash/z/ecc/android/sdk/WalletCoordinator.kt index ecad251f..7bf3a1c9 100644 --- a/sdk-incubator-lib/src/main/java/cash/z/ecc/android/sdk/WalletCoordinator.kt +++ b/sdk-incubator-lib/src/main/java/cash/z/ecc/android/sdk/WalletCoordinator.kt @@ -4,8 +4,6 @@ import android.content.Context import cash.z.ecc.android.sdk.ext.onFirst import cash.z.ecc.android.sdk.internal.Twig import cash.z.ecc.android.sdk.model.PersistableWallet -import cash.z.ecc.android.sdk.model.defaultForNetwork -import co.electriccoin.lightwallet.client.model.LightWalletEndpoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -77,7 +75,7 @@ class WalletCoordinator( val closeableSynchronizer = Synchronizer.new( context = context, zcashNetwork = persistableWallet.network, - lightWalletEndpoint = LightWalletEndpoint.defaultForNetwork(persistableWallet.network), + lightWalletEndpoint = persistableWallet.endpoint, birthday = persistableWallet.birthday, seed = persistableWallet.seedPhrase.toByteArray(), walletInitMode = persistableWallet.walletInitMode, diff --git a/sdk-incubator-lib/src/main/java/cash/z/ecc/android/sdk/model/PersistableWallet.kt b/sdk-incubator-lib/src/main/java/cash/z/ecc/android/sdk/model/PersistableWallet.kt index 41c96835..c2c7b9da 100644 --- a/sdk-incubator-lib/src/main/java/cash/z/ecc/android/sdk/model/PersistableWallet.kt +++ b/sdk-incubator-lib/src/main/java/cash/z/ecc/android/sdk/model/PersistableWallet.kt @@ -4,15 +4,28 @@ import android.app.Application import cash.z.ecc.android.bip39.Mnemonics import cash.z.ecc.android.bip39.toEntropy import cash.z.ecc.android.sdk.WalletInitMode +import co.electriccoin.lightwallet.client.model.LightWalletEndpoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.jetbrains.annotations.VisibleForTesting import org.json.JSONObject /** * Represents everything needed to save and restore a wallet. + * + * @param network the network in which the wallet operates + * @param endpoint the endpoint with witch the wallet communicates + * @param birthday the birthday of the wallet + * @param seedPhrase the seed phrase of the wallet + * @param walletInitMode required parameter with one of [WalletInitMode] values. Use [WalletInitMode.NewWallet] when + * starting synchronizer for a newly created wallet. Or use [WalletInitMode.RestoreWallet] when restoring an existing + * wallet that was created at some point in the past. Or use the last [WalletInitMode.ExistingWallet] type for a + * wallet which is already initialized and needs follow-up block synchronization. Note that this parameter is NOT + * persisted along with the rest of persistable wallet data. */ data class PersistableWallet( val network: ZcashNetwork, + val endpoint: LightWalletEndpoint = LightWalletEndpoint.defaultForNetwork(network), val birthday: BlockHeight?, val seedPhrase: SeedPhrase, val walletInitMode: WalletInitMode @@ -27,8 +40,11 @@ data class PersistableWallet( // Note: We're using a hand-crafted serializer so that we're less likely to have accidental // breakage from reflection or annotation based methods, and so that we can carefully manage versioning. fun toJson() = JSONObject().apply { - put(KEY_VERSION, VERSION_1) + put(KEY_VERSION, VERSION_2) put(KEY_NETWORK_ID, network.id) + put(KEY_ENDPOINT_HOST, endpoint.host) + put(KEY_ENDPOINT_PORT, endpoint.port) + put(KEY_ENDPOINT_IS_SECURE, endpoint.isSecure) birthday?.let { put(KEY_BIRTHDAY, it.value) } @@ -39,51 +55,92 @@ data class PersistableWallet( override fun toString() = "PersistableWallet" companion object { - private const val VERSION_1 = 1 + internal const val VERSION_1 = 1 + internal const val VERSION_2 = 2 internal const val KEY_VERSION = "v" internal const val KEY_NETWORK_ID = "network_ID" + internal const val KEY_ENDPOINT_HOST = "endpoint_host" + internal const val KEY_ENDPOINT_PORT = "key_endpoint_port" + internal const val KEY_ENDPOINT_IS_SECURE = "key_endpoint_is_secure" internal const val KEY_BIRTHDAY = "birthday" internal const val KEY_SEED_PHRASE = "seed_phrase" - // Note: This is not the ideal way to hold such a value. But we also want to avoid persisting the wallet - // initialization mode with the persistable wallet. + // Note: [walletInitMode] is excluded from the serialization to avoid persisting the wallet initialization mode + // with the persistable wallet. private var _walletInitMode: WalletInitMode = WalletInitMode.ExistingWallet fun from(jsonObject: JSONObject): PersistableWallet { - when (val version = jsonObject.getInt(KEY_VERSION)) { - VERSION_1 -> { - val network = run { - val networkId = jsonObject.getInt(KEY_NETWORK_ID) - ZcashNetwork.from(networkId) - } - val birthday = if (jsonObject.has(KEY_BIRTHDAY)) { - val birthdayBlockHeightLong = jsonObject.getLong(KEY_BIRTHDAY) - BlockHeight.new(network, birthdayBlockHeightLong) - } else { - null - } - val seedPhrase = jsonObject.getString(KEY_SEED_PHRASE) + // Common parameters + val network = getNetwork(jsonObject) + val birthday = getBirthday(jsonObject, network) + val seedPhrase = getSeedPhrase(jsonObject) + // From version 2 + val endpoint: LightWalletEndpoint - return PersistableWallet( - network = network, - birthday = birthday, - seedPhrase = SeedPhrase.new(seedPhrase), - walletInitMode = _walletInitMode - ) + when (val version = getVersion(jsonObject)) { + VERSION_1 -> { + endpoint = LightWalletEndpoint.defaultForNetwork(network) + } + VERSION_2 -> { + endpoint = getEndpoint(jsonObject) } else -> { throw IllegalArgumentException("Unsupported version $version") } } + + return PersistableWallet( + network = network, + endpoint = endpoint, + birthday = birthday, + seedPhrase = SeedPhrase.new(seedPhrase), + walletInitMode = _walletInitMode + ) + } + + internal fun getVersion(jsonObject: JSONObject): Int { + return jsonObject.getInt(KEY_VERSION) + } + internal fun getSeedPhrase(jsonObject: JSONObject): String { + return jsonObject.getString(KEY_SEED_PHRASE) + } + internal fun getNetwork(jsonObject: JSONObject): ZcashNetwork { + val networkId = jsonObject.getInt(KEY_NETWORK_ID) + return ZcashNetwork.from(networkId) + } + internal fun getBirthday(jsonObject: JSONObject, network: ZcashNetwork): BlockHeight? { + return if (jsonObject.has(KEY_BIRTHDAY)) { + val birthdayBlockHeightLong = jsonObject.getLong(KEY_BIRTHDAY) + BlockHeight.new(network, birthdayBlockHeightLong) + } else { + null + } + } + internal fun getEndpoint(jsonObject: JSONObject): LightWalletEndpoint { + return jsonObject.run { + val host = getString(KEY_ENDPOINT_HOST) + val port = getInt(KEY_ENDPOINT_PORT) + val isSecure = getBoolean(KEY_ENDPOINT_IS_SECURE) + LightWalletEndpoint(host, port, isSecure) + } } /** * @return A new PersistableWallet with a random seed phrase. + * + * @param zcashNetwork the network in which the wallet operates + * @param endpoint the endpoint with witch the wallet communicates + * @param walletInitMode required parameter with one of [WalletInitMode] values. Use [WalletInitMode.NewWallet] + * when starting synchronizer for a newly created wallet. Or use [WalletInitMode.RestoreWallet] when + * restoring an existing wallet that was created at some point in the past. Or use the last [WalletInitMode + * .ExistingWallet] type for a wallet which is already initialized and needs follow-up block synchronization. + * Note that this parameter is NOT persisted along with the rest of persistable wallet data. */ suspend fun new( application: Application, zcashNetwork: ZcashNetwork, + endpoint: LightWalletEndpoint = LightWalletEndpoint.defaultForNetwork(zcashNetwork), walletInitMode: WalletInitMode ): PersistableWallet { val birthday = BlockHeight.ofLatestCheckpoint(application, zcashNetwork) @@ -92,11 +149,38 @@ data class PersistableWallet( return PersistableWallet( zcashNetwork, + endpoint, birthday, seedPhrase, walletInitMode ) } + + /** + * Note: this function is internal and allowed only for testing purposes. + * + * @return Wallet serialized to JSON format, suitable for long-term encrypted storage. + */ + @VisibleForTesting + internal fun toCustomJson( + version: Int, + network: ZcashNetwork, + endpoint: LightWalletEndpoint?, + birthday: BlockHeight?, + seed: SeedPhrase + ) = JSONObject().apply { + put(KEY_VERSION, version) + put(KEY_NETWORK_ID, network.id) + endpoint?.let { + put(KEY_ENDPOINT_HOST, it.host) + put(KEY_ENDPOINT_PORT, it.port) + put(KEY_ENDPOINT_IS_SECURE, it.isSecure) + } + birthday?.let { + put(KEY_BIRTHDAY, it.value) + } + put(KEY_SEED_PHRASE, seed.joinToString()) + } } }