[#1095] PersistableWallet endpoint API

* [#1095] PersistableWallet endpoint API

* Fix Ktlint warnings

* Add PersistableWallet 1_2 migration test

* Changelog update
This commit is contained in:
Honza Rychnovský 2023-09-28 09:54:27 +02:00 committed by GitHub
parent 1032f88035
commit 6bf7b12982
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 230 additions and 44 deletions

View File

@ -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

View File

@ -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<Application>()
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)
}

View File

@ -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)
}

View File

@ -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
)
}

View File

@ -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
)
}
}

View File

@ -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,

View File

@ -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())
}
}
}