You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
512 lines
19 KiB
512 lines
19 KiB
package cash.z.ecc.android.sdk
|
|
|
|
import android.content.Context
|
|
import cash.z.ecc.android.sdk.exception.InitializerException
|
|
import cash.z.ecc.android.sdk.ext.ZcashSdk
|
|
import cash.z.ecc.android.sdk.internal.SdkDispatchers
|
|
import cash.z.ecc.android.sdk.internal.ext.getCacheDirSuspend
|
|
import cash.z.ecc.android.sdk.internal.ext.getDatabasePathSuspend
|
|
import cash.z.ecc.android.sdk.internal.model.Checkpoint
|
|
import cash.z.ecc.android.sdk.internal.twig
|
|
import cash.z.ecc.android.sdk.jni.RustBackend
|
|
import cash.z.ecc.android.sdk.model.BlockHeight
|
|
import cash.z.ecc.android.sdk.model.LightWalletEndpoint
|
|
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
|
import cash.z.ecc.android.sdk.tool.CheckpointTool
|
|
import cash.z.ecc.android.sdk.tool.DerivationTool
|
|
import cash.z.ecc.android.sdk.type.UnifiedViewingKey
|
|
import kotlinx.coroutines.runBlocking
|
|
import kotlinx.coroutines.withContext
|
|
import java.io.File
|
|
|
|
/**
|
|
* Simplified Initializer focused on starting from a ViewingKey.
|
|
*/
|
|
@Suppress("LongParameterList")
|
|
class Initializer private constructor(
|
|
val context: Context,
|
|
internal val rustBackend: RustBackend,
|
|
val network: ZcashNetwork,
|
|
val alias: String,
|
|
val lightWalletEndpoint: LightWalletEndpoint,
|
|
val viewingKeys: List<UnifiedViewingKey>,
|
|
val overwriteVks: Boolean,
|
|
internal val checkpoint: Checkpoint
|
|
) {
|
|
|
|
suspend fun erase() = erase(context, network, alias)
|
|
|
|
class Config private constructor(
|
|
val viewingKeys: MutableList<UnifiedViewingKey> = mutableListOf(),
|
|
var alias: String = ZcashSdk.DEFAULT_ALIAS
|
|
) {
|
|
var birthdayHeight: BlockHeight? = null
|
|
private set
|
|
|
|
lateinit var network: ZcashNetwork
|
|
private set
|
|
|
|
lateinit var lightWalletEndpoint: LightWalletEndpoint
|
|
private set
|
|
|
|
/**
|
|
* Determines the default behavior for null birthdays. When null, nothing has been specified
|
|
* so a null birthdayHeight value is an error. When false, null birthdays will be replaced
|
|
* with the most recent checkpoint height available (typically, the latest `*.json` file in
|
|
* `assets/co.electriccoin.zcash/checkpoint/`). When true, null birthdays will be replaced with the oldest
|
|
* reasonable height where a transaction could exist (typically, sapling activation but
|
|
* better approximations could be devised in the future, such as the date when the first
|
|
* BIP-39 zcash wallets came online).
|
|
*/
|
|
var defaultToOldestHeight: Boolean? = null
|
|
private set
|
|
|
|
var overwriteVks: Boolean = false
|
|
private set
|
|
|
|
constructor(block: (Config) -> Unit) : this() {
|
|
block(this)
|
|
}
|
|
|
|
//
|
|
// Birthday functions
|
|
//
|
|
|
|
/**
|
|
* Set the birthday height for this configuration. When the height is not known, the wallet
|
|
* can either default to the latest known birthday (in order to sync new wallets faster) or
|
|
* the oldest possible birthday (in order to import a wallet with an unknown birthday
|
|
* without skipping old transactions).
|
|
*
|
|
* @param height nullable birthday height to use for this configuration.
|
|
* @param defaultToOldestHeight determines how a null birthday height will be
|
|
* interpreted. Typically, `false` for new wallets and `true` for restored wallets because
|
|
* new wallets want to load quickly but restored wallets want to find all possible
|
|
* transactions. Again, this value is only considered when [height] is null.
|
|
*
|
|
*/
|
|
fun setBirthdayHeight(height: BlockHeight?, defaultToOldestHeight: Boolean): Config =
|
|
apply {
|
|
this.birthdayHeight = height
|
|
this.defaultToOldestHeight = defaultToOldestHeight
|
|
}
|
|
|
|
/**
|
|
* Load the most recent checkpoint available. This is useful for new wallets.
|
|
*/
|
|
fun newWalletBirthday(): Config = apply {
|
|
birthdayHeight = null
|
|
defaultToOldestHeight = false
|
|
}
|
|
|
|
/**
|
|
* Load the birthday checkpoint closest to the given wallet birthday. This is useful when
|
|
* importing a pre-existing wallet. It is the same as calling
|
|
* `birthdayHeight = importedHeight`.
|
|
*/
|
|
fun importedWalletBirthday(importedHeight: BlockHeight?): Config = apply {
|
|
birthdayHeight = importedHeight
|
|
defaultToOldestHeight = true
|
|
}
|
|
|
|
//
|
|
// Viewing key functions
|
|
//
|
|
|
|
/**
|
|
* Add viewing keys to the set of accounts to monitor. Note: Using more than one viewing key
|
|
* is not currently well supported. Consider it an alpha-preview feature that might work but
|
|
* probably has serious bugs.
|
|
*/
|
|
fun setViewingKeys(
|
|
vararg unifiedViewingKeys: UnifiedViewingKey,
|
|
overwrite: Boolean = false
|
|
): Config = apply {
|
|
overwriteVks = overwrite
|
|
viewingKeys.apply {
|
|
clear()
|
|
addAll(unifiedViewingKeys)
|
|
}
|
|
}
|
|
|
|
fun setOverwriteKeys(isOverwrite: Boolean) {
|
|
overwriteVks = isOverwrite
|
|
}
|
|
|
|
/**
|
|
* Add viewing key to the set of accounts to monitor. Note: Using more than one viewing key
|
|
* is not currently well supported. Consider it an alpha-preview feature that might work but
|
|
* probably has serious bugs.
|
|
*/
|
|
fun addViewingKey(unifiedFullViewingKey: UnifiedViewingKey): Config = apply {
|
|
viewingKeys.add(unifiedFullViewingKey)
|
|
}
|
|
|
|
//
|
|
// Convenience functions
|
|
//
|
|
|
|
/**
|
|
* Set the server and the network property at the same time to prevent them from getting out
|
|
* of sync. Ultimately, this determines which host a synchronizer will use in order to
|
|
* connect to lightwalletd. In most cases, the default host is sufficient but an override
|
|
* can be provided. The host cannot be changed without explicitly setting the network.
|
|
*
|
|
* @param network the Zcash network to use. Either testnet or mainnet.
|
|
* @param host the lightwalletd host to use.
|
|
* @param port the lightwalletd port to use.
|
|
*/
|
|
fun setNetwork(
|
|
network: ZcashNetwork,
|
|
lightWalletEndpoint: LightWalletEndpoint
|
|
): Config = apply {
|
|
this.network = network
|
|
this.lightWalletEndpoint = lightWalletEndpoint
|
|
}
|
|
|
|
/**
|
|
* Import a wallet using the first viewing key derived from the given seed.
|
|
*/
|
|
suspend fun importWallet(
|
|
seed: ByteArray,
|
|
birthday: BlockHeight?,
|
|
network: ZcashNetwork,
|
|
lightWalletEndpoint: LightWalletEndpoint,
|
|
alias: String = ZcashSdk.DEFAULT_ALIAS
|
|
): Config =
|
|
importWallet(
|
|
DerivationTool.deriveUnifiedViewingKeys(seed, network = network)[0],
|
|
birthday,
|
|
network,
|
|
lightWalletEndpoint,
|
|
alias
|
|
)
|
|
|
|
/**
|
|
* Default function for importing a wallet.
|
|
*/
|
|
fun importWallet(
|
|
viewingKey: UnifiedViewingKey,
|
|
birthday: BlockHeight?,
|
|
network: ZcashNetwork,
|
|
lightWalletEndpoint: LightWalletEndpoint,
|
|
alias: String = ZcashSdk.DEFAULT_ALIAS
|
|
): Config = apply {
|
|
setViewingKeys(viewingKey)
|
|
setNetwork(network, lightWalletEndpoint)
|
|
importedWalletBirthday(birthday)
|
|
this.alias = alias
|
|
}
|
|
|
|
/**
|
|
* Create a new wallet using the first viewing key derived from the given seed.
|
|
*/
|
|
suspend fun newWallet(
|
|
seed: ByteArray,
|
|
network: ZcashNetwork,
|
|
lightWalletEndpoint: LightWalletEndpoint,
|
|
alias: String = ZcashSdk.DEFAULT_ALIAS
|
|
): Config = newWallet(
|
|
DerivationTool.deriveUnifiedViewingKeys(seed, network)[0],
|
|
network,
|
|
lightWalletEndpoint,
|
|
alias
|
|
)
|
|
|
|
/**
|
|
* Default function for creating a new wallet.
|
|
*/
|
|
fun newWallet(
|
|
viewingKey: UnifiedViewingKey,
|
|
network: ZcashNetwork,
|
|
lightWalletEndpoint: LightWalletEndpoint,
|
|
alias: String = ZcashSdk.DEFAULT_ALIAS
|
|
): Config = apply {
|
|
setViewingKeys(viewingKey)
|
|
setNetwork(network, lightWalletEndpoint)
|
|
newWalletBirthday()
|
|
this.alias = alias
|
|
}
|
|
|
|
/**
|
|
* Convenience method for setting thew viewingKeys from a given seed. This is the same as
|
|
* calling `setViewingKeys` with the keys that match this seed.
|
|
*/
|
|
suspend fun setSeed(
|
|
seed: ByteArray,
|
|
network: ZcashNetwork,
|
|
numberOfAccounts: Int = 1
|
|
): Config =
|
|
apply {
|
|
setViewingKeys(
|
|
*DerivationTool.deriveUnifiedViewingKeys(
|
|
seed,
|
|
network,
|
|
numberOfAccounts
|
|
)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Sets the network from a network id, throwing an exception if the id is not recognized.
|
|
*
|
|
* @param networkId the ID of the network corresponding to the [ZcashNetwork] enum.
|
|
* Typically, it is 0 for testnet and 1 for mainnet.
|
|
*/
|
|
fun setNetworkId(networkId: Int): Config = apply {
|
|
network = ZcashNetwork.from(networkId)
|
|
}
|
|
|
|
//
|
|
// Validation helpers
|
|
//
|
|
|
|
fun validate(): Config = apply {
|
|
validateAlias(alias)
|
|
validateViewingKeys()
|
|
validateBirthday()
|
|
}
|
|
|
|
private fun validateBirthday() {
|
|
// if birthday is missing then we need to know how to interpret it
|
|
// so defaultToOldestHeight ought to be set, in that case
|
|
if (birthdayHeight == null && defaultToOldestHeight == null) {
|
|
throw InitializerException.MissingDefaultBirthdayException
|
|
}
|
|
// allow either null or a value greater than the activation height
|
|
if (
|
|
(birthdayHeight?.value ?: network.saplingActivationHeight.value)
|
|
< network.saplingActivationHeight.value
|
|
) {
|
|
throw InitializerException.InvalidBirthdayHeightException(birthdayHeight, network)
|
|
}
|
|
}
|
|
|
|
private fun validateViewingKeys() {
|
|
require(viewingKeys.isNotEmpty()) {
|
|
"Unified Viewing keys are required. Ensure that the unified viewing keys or seed" +
|
|
" have been set on this Initializer."
|
|
}
|
|
viewingKeys.forEach {
|
|
DerivationTool.validateUnifiedViewingKey(it)
|
|
}
|
|
}
|
|
|
|
companion object
|
|
}
|
|
|
|
companion object : SdkSynchronizer.Erasable {
|
|
|
|
suspend fun new(appContext: Context, config: Config) = new(appContext, null, config)
|
|
|
|
fun newBlocking(appContext: Context, config: Config) = runBlocking {
|
|
new(
|
|
appContext,
|
|
null,
|
|
config
|
|
)
|
|
}
|
|
|
|
suspend fun new(
|
|
appContext: Context,
|
|
onCriticalErrorHandler: ((Throwable?) -> Boolean)? = null,
|
|
block: (Config) -> Unit
|
|
) = new(appContext, onCriticalErrorHandler, Config(block))
|
|
|
|
suspend fun new(
|
|
context: Context,
|
|
onCriticalErrorHandler: ((Throwable?) -> Boolean)?,
|
|
config: Config
|
|
): Initializer {
|
|
config.validate()
|
|
|
|
val loadedCheckpoint = run {
|
|
val height = config.birthdayHeight
|
|
?: if (config.defaultToOldestHeight == true) {
|
|
config.network.saplingActivationHeight
|
|
} else {
|
|
null
|
|
}
|
|
|
|
CheckpointTool.loadNearest(
|
|
context,
|
|
config.network,
|
|
height
|
|
)
|
|
}
|
|
|
|
val rustBackend = initRustBackend(context, config.network, config.alias, loadedCheckpoint.height)
|
|
|
|
return Initializer(
|
|
context.applicationContext,
|
|
rustBackend,
|
|
config.network,
|
|
config.alias,
|
|
config.lightWalletEndpoint,
|
|
config.viewingKeys,
|
|
config.overwriteVks,
|
|
loadedCheckpoint
|
|
)
|
|
}
|
|
|
|
private fun onCriticalError(onCriticalErrorHandler: ((Throwable?) -> Boolean)?, error: Throwable) {
|
|
twig("********")
|
|
twig("******** INITIALIZER ERROR: $error")
|
|
if (error.cause != null) twig("******** caused by ${error.cause}")
|
|
if (error.cause?.cause != null) twig("******** caused by ${error.cause?.cause}")
|
|
twig("********")
|
|
twig(error)
|
|
|
|
if (onCriticalErrorHandler == null) {
|
|
twig(
|
|
"WARNING: a critical error occurred on the Initializer but no callback is " +
|
|
"registered to be notified of critical errors! THIS IS PROBABLY A MISTAKE. To " +
|
|
"respond to these errors (perhaps to update the UI or alert the user) set " +
|
|
"initializer.onCriticalErrorHandler to a non-null value or use the secondary " +
|
|
"constructor: Initializer(context, handler) { ... }. Note that the synchronizer " +
|
|
"and initializer BOTH have error handlers and since the initializer exists " +
|
|
"before the synchronizer, it needs its error handler set separately."
|
|
)
|
|
}
|
|
|
|
onCriticalErrorHandler?.invoke(error)
|
|
}
|
|
|
|
private suspend fun initRustBackend(
|
|
context: Context,
|
|
network: ZcashNetwork,
|
|
alias: String,
|
|
blockHeight: BlockHeight
|
|
): RustBackend {
|
|
return RustBackend.init(
|
|
cacheDbPath(context, network, alias),
|
|
dataDbPath(context, network, alias),
|
|
File(context.getCacheDirSuspend(), "params").absolutePath,
|
|
network,
|
|
blockHeight
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Delete the databases associated with this wallet. This removes all compact blocks and
|
|
* data derived from those blocks. For most wallets, this should not result in a loss of
|
|
* funds because the seed and spending keys are stored separately. This call just removes
|
|
* the associated data but not the seed or spending key, themselves, because those are
|
|
* managed separately by the wallet.
|
|
*
|
|
* @param appContext the application context.
|
|
* @param network the network associated with the data to be erased.
|
|
* @param alias the alias used to create the local data.
|
|
*
|
|
* @return true when one of the associated files was found. False most likely indicates
|
|
* that the wrong alias was provided.
|
|
*/
|
|
override suspend fun erase(
|
|
appContext: Context,
|
|
network: ZcashNetwork,
|
|
alias: String
|
|
): Boolean {
|
|
val cacheDeleted = deleteDb(cacheDbPath(appContext, network, alias))
|
|
val dataDeleted = deleteDb(dataDbPath(appContext, network, alias))
|
|
return dataDeleted || cacheDeleted
|
|
}
|
|
|
|
//
|
|
// Path Helpers
|
|
//
|
|
|
|
/**
|
|
* Returns the path to the cache database that would correspond to the given alias.
|
|
*
|
|
* @param appContext the application context
|
|
* @param network the network associated with the data in the cache database.
|
|
* @param alias the alias to convert into a database path
|
|
*/
|
|
private suspend fun cacheDbPath(
|
|
appContext: Context,
|
|
network: ZcashNetwork,
|
|
alias: String
|
|
): String =
|
|
aliasToPath(appContext, network, alias, ZcashSdk.DB_CACHE_NAME)
|
|
|
|
/**
|
|
* Returns the path to the data database that would correspond to the given alias.
|
|
* @param appContext the application context
|
|
* * @param network the network associated with the data in the database.
|
|
* @param alias the alias to convert into a database path
|
|
*/
|
|
private suspend fun dataDbPath(
|
|
appContext: Context,
|
|
network: ZcashNetwork,
|
|
alias: String
|
|
): String =
|
|
aliasToPath(appContext, network, alias, ZcashSdk.DB_DATA_NAME)
|
|
|
|
private suspend fun aliasToPath(
|
|
appContext: Context,
|
|
network: ZcashNetwork,
|
|
alias: String,
|
|
dbFileName: String
|
|
): String {
|
|
val parentDir: String =
|
|
appContext.getDatabasePathSuspend("unused.db").parentFile?.absolutePath
|
|
?: throw InitializerException.DatabasePathException
|
|
val prefix = if (alias.endsWith('_')) alias else "${alias}_"
|
|
return File(parentDir, "$prefix${network.networkName}_$dbFileName").absolutePath
|
|
}
|
|
|
|
/**
|
|
* Delete a database and it's potential journal file at the given path.
|
|
*
|
|
* @param filePath the path of the db to erase.
|
|
* @return true when a file exists at the given path and was deleted.
|
|
*/
|
|
private suspend fun deleteDb(filePath: String): Boolean {
|
|
// just try the journal file. Doesn't matter if it's not there.
|
|
delete("$filePath-journal")
|
|
|
|
return delete(filePath)
|
|
}
|
|
|
|
/**
|
|
* Delete the file at the given path.
|
|
*
|
|
* @param filePath the path of the file to erase.
|
|
* @return true when a file exists at the given path and was deleted.
|
|
*/
|
|
private suspend fun delete(filePath: String): Boolean {
|
|
return File(filePath).let {
|
|
withContext(SdkDispatchers.DATABASE_IO) {
|
|
if (it.exists()) {
|
|
twig("Deleting ${it.name}!")
|
|
it.delete()
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate that the alias doesn't contain malicious characters by enforcing simple rules which
|
|
* permit the alias to be used as part of a file name for the preferences and databases. This
|
|
* enables multiple wallets to exist on one device, which is also helpful for sweeping funds.
|
|
*
|
|
* @param alias the alias to validate.
|
|
*
|
|
* @throws IllegalArgumentException whenever the alias is not less than 100 characters or
|
|
* contains something other than alphanumeric characters. Underscores are allowed but aliases
|
|
* must start with a letter.
|
|
*/
|
|
internal fun validateAlias(alias: String) {
|
|
require(
|
|
alias.length in 1..99 && alias[0].isLetter() &&
|
|
alias.all { it.isLetterOrDigit() || it == '_' }
|
|
) {
|
|
"ERROR: Invalid alias ($alias). For security, the alias must be shorter than 100 " +
|
|
"characters and only contain letters, digits or underscores and start with a letter."
|
|
}
|
|
}
|
|
|