[#477] Typesafe Zatoshi APIs for amounts

This commit is contained in:
Carter Jernigan 2022-06-21 19:34:42 -04:00 committed by GitHub
parent 62e5cee5dd
commit 823e8387cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 255 additions and 138 deletions

View File

@ -3,6 +3,10 @@ Change Log
Upcoming
------------------------------------
- Added `Zatoshi` typesafe object to represent amounts instead.
Version 1.6.0-beta01
------------------------------------
- Updated checkpoints for Mainnet and Testnet
- Fix: SDK can now be used on Intel x86_64 emulators
- Prevent R8 warnings for apps consuming the SDK

View File

@ -1,6 +1,16 @@
Troubleshooting Migrations
==========
Upcoming Migration to Version 1.7 from 1.6
--------------------------------------
Various APIs used `Long` value to represent Zatoshi currency amounts. Those APIs now use a typesafe `Zatoshi` class. When passing amounts, simply wrap Long values with the Zatoshi constructor `Zatoshi(Long)`. When receiving values, simply unwrap Long values with `Zatoshi.value`.
`WalletBalance` no longer has uninitialized default values. This means that `Synchronizer` fields that expose a WalletBalance now use `null` to signal an uninitialized value. Specifically this means `Synchronizer.orchardBalances`, `Synchronzier.saplingBalances`, and `Synchronizer.transparentBalances` have nullable values now.
`ZcashSdk.ZATOSHI_PER_ZEC` has been moved to `Zatoshi.ZATOSHI_PER_ZEC`.
`ZcashSdk.MINERS_FEE_ZATOSHI` has been renamed to `ZcashSdk.MINERS_FEE` and the type has changed from `Long` to `Zatoshi`.
Upcoming Migrating to Version 1.4.* from 1.3.*
--------------------------------------
The main entrypoint to the SDK has changed.

View File

@ -203,19 +203,19 @@ class DarksideTestCoordinator(val wallet: TestWallet) {
fun validateMinBalance(available: Long = -1, total: Long = -1) {
val balance = synchronizer.saplingBalances.value
if (available > 0) {
assertTrue("invalid available balance. Expected a minimum of $available but found ${balance.availableZatoshi}", available <= balance.availableZatoshi)
assertTrue("invalid available balance. Expected a minimum of $available but found ${balance?.available}", available <= balance?.available?.value!!)
}
if (total > 0) {
assertTrue("invalid total balance. Expected a minimum of $total but found ${balance.totalZatoshi}", total <= balance.totalZatoshi)
assertTrue("invalid total balance. Expected a minimum of $total but found ${balance?.total}", total <= balance?.total?.value!!)
}
}
suspend fun validateBalance(available: Long = -1, total: Long = -1, accountIndex: Int = 0) {
val balance = (synchronizer as SdkSynchronizer).processor.getBalanceInfo(accountIndex)
if (available > 0) {
assertEquals("invalid available balance", available, balance.availableZatoshi)
assertEquals("invalid available balance", available, balance.available)
}
if (total > 0) {
assertEquals("invalid total balance", total, balance.totalZatoshi)
assertEquals("invalid total balance", total, balance.total)
}
}
}

View File

@ -10,6 +10,7 @@ import cash.z.ecc.android.sdk.db.entity.isPending
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.internal.service.LightWalletGrpcService
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.tool.DerivationTool
import cash.z.ecc.android.sdk.type.WalletBalance
import cash.z.ecc.android.sdk.type.ZcashNetwork
@ -70,7 +71,7 @@ class TestWallet(
val synchronizer: SdkSynchronizer = runBlocking { Synchronizer.new(initializer) } as SdkSynchronizer
val service = (synchronizer.processor.downloader.lightWalletService as LightWalletGrpcService)
val available get() = synchronizer.saplingBalances.value.availableZatoshi
val available get() = synchronizer.saplingBalances.value?.available
val shieldedAddress =
runBlocking { DerivationTool.deriveShieldedAddress(seed, network = network) }
val transparentAddress =
@ -105,7 +106,7 @@ class TestWallet(
return this
}
suspend fun send(address: String = transparentAddress, memo: String = "", amount: Long = 500L, fromAccountIndex: Int = 0): TestWallet {
suspend fun send(address: String = transparentAddress, memo: String = "", amount: Zatoshi = Zatoshi(500L), fromAccountIndex: Int = 0): TestWallet {
Twig.sprout("$alias sending")
synchronizer.sendToAddress(shieldedSpendingKey, amount, address, memo, fromAccountIndex)
.takeWhile { it.isPending() }
@ -128,9 +129,9 @@ class TestWallet(
}
synchronizer.getTransparentBalance(transparentAddress).let { walletBalance ->
twig("FOUND utxo balance of total: ${walletBalance.totalZatoshi} available: ${walletBalance.availableZatoshi}")
twig("FOUND utxo balance of total: ${walletBalance.total} available: ${walletBalance.available}")
if (walletBalance.availableZatoshi > 0L) {
if (walletBalance.available.value > 0L) {
synchronizer.shieldFunds(shieldedSpendingKey, transparentSecretKey)
.onCompletion { twig("done shielding funds") }
.catch { twig("Failed with $it") }

View File

@ -17,6 +17,7 @@ import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
import cash.z.ecc.android.sdk.tool.DerivationTool
import cash.z.ecc.android.sdk.type.WalletBalance
import cash.z.ecc.android.sdk.type.ZcashNetwork
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.runBlocking
/**
@ -68,22 +69,23 @@ class GetBalanceFragment : BaseDemoFragment<FragmentGetBalanceBinding>() {
synchronizer.status.collectWith(lifecycleScope, ::onStatus)
synchronizer.progress.collectWith(lifecycleScope, ::onProgress)
synchronizer.processorInfo.collectWith(lifecycleScope, ::onProcessorInfoUpdated)
synchronizer.saplingBalances.collectWith(lifecycleScope, ::onBalance)
synchronizer.saplingBalances.filterNotNull().collectWith(lifecycleScope, ::onBalance)
}
private fun onBalance(balance: WalletBalance) {
binding.textBalance.text = """
Available balance: ${balance.availableZatoshi.convertZatoshiToZecString(12)}
Total balance: ${balance.totalZatoshi.convertZatoshiToZecString(12)}
Available balance: ${balance.available.convertZatoshiToZecString(12)}
Total balance: ${balance.total.convertZatoshiToZecString(12)}
""".trimIndent()
}
private fun onStatus(status: Synchronizer.Status) {
binding.textStatus.text = "Status: $status"
if (WalletBalance().none()) {
val balance = synchronizer.saplingBalances.value
if (null == balance) {
binding.textBalance.text = "Calculating balance..."
} else {
onBalance(synchronizer.saplingBalances.value)
onBalance(balance)
}
}
@ -93,16 +95,6 @@ class GetBalanceFragment : BaseDemoFragment<FragmentGetBalanceBinding>() {
}
}
/**
* Extension function which checks if the balance has been updated or its -1
*/
private fun WalletBalance.none(): Boolean {
if (synchronizer.saplingBalances.value.totalZatoshi == -1L &&
synchronizer.saplingBalances.value.availableZatoshi == -1L
) return true
return false
}
private fun onProcessorInfoUpdated(info: CompactBlockProcessor.ProcessorInfo) {
if (info.isScanning) binding.textStatus.text = "Scanning blocks...${info.scanProgress}%"
}

View File

@ -6,6 +6,7 @@ import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import cash.z.ecc.android.sdk.db.entity.ConfirmedTransaction
import cash.z.ecc.android.sdk.db.entity.valueInZatoshi
import cash.z.ecc.android.sdk.demoapp.R
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
import java.text.SimpleDateFormat
@ -23,7 +24,7 @@ class TransactionViewHolder<T : ConfirmedTransaction>(itemView: View) : Recycler
fun bindTo(transaction: T?) {
val isInbound = transaction?.toAddress.isNullOrEmpty()
amountText.text = transaction?.value.convertZatoshiToZecString()
amountText.text = transaction?.valueInZatoshi.convertZatoshiToZecString()
timeText.text =
if (transaction == null || transaction?.blockTimeInSeconds == 0L) "Pending"
else formatter.format(transaction.blockTimeInSeconds * 1000L)

View File

@ -4,6 +4,7 @@ import android.view.View
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import cash.z.ecc.android.sdk.db.entity.ConfirmedTransaction
import cash.z.ecc.android.sdk.db.entity.valueInZatoshi
import cash.z.ecc.android.sdk.demoapp.R
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
import java.text.SimpleDateFormat
@ -19,7 +20,7 @@ class UtxoViewHolder<T : ConfirmedTransaction>(itemView: View) : RecyclerView.Vi
private val formatter = SimpleDateFormat("M/d h:mma", Locale.getDefault())
fun bindTo(transaction: T?) {
amountText.text = transaction?.value.convertZatoshiToZecString()
amountText.text = transaction?.valueInZatoshi.convertZatoshiToZecString()
timeText.text =
if (transaction == null || transaction?.blockTimeInSeconds == 0L) "Pending"
else formatter.format(transaction.blockTimeInSeconds * 1000L)

View File

@ -79,7 +79,7 @@ class SendFragment : BaseDemoFragment<FragmentSendBinding>() {
// Observable properties (done without livedata or flows for simplicity)
//
private var balance = WalletBalance()
private var balance: WalletBalance? = null
set(value) {
field = value
onUpdateSendButton()
@ -144,12 +144,12 @@ class SendFragment : BaseDemoFragment<FragmentSendBinding>() {
if (info.isScanning) binding.textStatus.text = "Scanning blocks...${info.scanProgress}%"
}
private fun onBalance(balance: WalletBalance) {
private fun onBalance(balance: WalletBalance?) {
this.balance = balance
if (!isSyncing) {
binding.textBalance.text = """
Available balance: ${balance.availableZatoshi.convertZatoshiToZecString(12)}
Total balance: ${balance.totalZatoshi.convertZatoshiToZecString(12)}
Available balance: ${balance?.available.convertZatoshiToZecString(12)}
Total balance: ${balance?.total.convertZatoshiToZecString(12)}
""".trimIndent()
}
}
@ -196,7 +196,7 @@ class SendFragment : BaseDemoFragment<FragmentSendBinding>() {
text = "⌛ syncing"
isEnabled = false
}
balance.availableZatoshi <= 0 -> isEnabled = false
(balance?.available?.value ?: 0) <= 0 -> isEnabled = false
else -> {
text = "send"
isEnabled = true

View File

@ -22,7 +22,7 @@ RELEASE_SIGNING_ENABLED=false
# Required by the maven publishing plugin
SONATYPE_HOST=DEFAULT
LIBRARY_VERSION=1.6.0-beta01
LIBRARY_VERSION=1.7.0-beta01
# Kotlin compiler warnings can be considered errors, failing the build.
# Currently set to false, because this project has a lot of warnings to fix first.

View File

@ -247,6 +247,7 @@ dependencies {
// Tests
testImplementation(libs.kotlin.reflect)
testImplementation(libs.kotlin.test)
testImplementation(libs.mockito.junit)
testImplementation(libs.mockito.kotlin)
testImplementation(libs.bundles.junit)

View File

@ -12,12 +12,14 @@ import cash.z.ecc.android.sdk.internal.TroubleshootingTwig
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.internal.service.LightWalletGrpcService
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.test.ScopedTest
import cash.z.ecc.android.sdk.tool.DerivationTool
import cash.z.ecc.android.sdk.tool.WalletBirthdayTool
import cash.z.ecc.android.sdk.type.ZcashNetwork
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking
@ -67,9 +69,9 @@ class TestnetIntegrationTest : ScopedTest() {
@LargeTest
@Ignore("This test is extremely slow")
fun testBalance() = runBlocking {
var availableBalance: Long = 0L
var availableBalance: Zatoshi? = null
synchronizer.saplingBalances.onFirst {
availableBalance = it.availableZatoshi
availableBalance = it?.available
}
synchronizer.status.filter { it == SYNCED }.onFirst {
@ -78,7 +80,7 @@ class TestnetIntegrationTest : ScopedTest() {
assertTrue(
"No funds available when we expected a balance greater than zero!",
availableBalance > 0
availableBalance!!.value > 0
)
}
@ -86,7 +88,7 @@ class TestnetIntegrationTest : ScopedTest() {
@Ignore("This test is broken")
fun testSpend() = runBlocking {
var success = false
synchronizer.saplingBalances.filter { it.availableZatoshi > 0 }.onEach {
synchronizer.saplingBalances.filterNotNull().onEach {
success = sendFunds()
}.first()
log("asserting $success")
@ -98,7 +100,7 @@ class TestnetIntegrationTest : ScopedTest() {
log("sending to address")
synchronizer.sendToAddress(
spendingKey,
ZcashSdk.MINERS_FEE_ZATOSHI,
ZcashSdk.MINERS_FEE,
toAddress,
"first mainnet tx from the SDK"
).filter { it?.isSubmitSuccess() == true }.onFirst {

View File

@ -11,6 +11,7 @@ import cash.z.ecc.android.sdk.internal.TroubleshootingTwig
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.internal.service.LightWalletService
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.test.ScopedTest
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.stub
@ -71,7 +72,7 @@ class PersistentTransactionManagerTest : ScopedTest() {
@Test
fun testCancellation_RaceCondition() = runBlocking {
val tx = manager.initSpend(1234, "taddr", "memo-good", 0)
val tx = manager.initSpend(Zatoshi(1234), "taddr", "memo-good", 0)
val txFlow = manager.monitorById(tx.id)
// encode TX
@ -95,7 +96,7 @@ class PersistentTransactionManagerTest : ScopedTest() {
@Test
fun testCancel() = runBlocking {
var tx = manager.initSpend(1234, "a", "b", 0)
var tx = manager.initSpend(Zatoshi(1234), "a", "b", 0)
assertFalse(tx.isCancelled())
manager.cancel(tx.id)
tx = manager.findById(tx.id)!!
@ -104,7 +105,7 @@ class PersistentTransactionManagerTest : ScopedTest() {
@Test
fun testAbort() = runBlocking {
var tx: PendingTransaction? = manager.initSpend(1234, "a", "b", 0)
var tx: PendingTransaction? = manager.initSpend(Zatoshi(1234), "a", "b", 0)
assertNotNull(tx)
manager.abort(tx!!)
tx = manager.findById(tx.id)

View File

@ -1,6 +1,7 @@
package cash.z.ecc.android.sdk.sample
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.type.ZcashNetwork
import cash.z.ecc.android.sdk.util.TestWallet
import kotlinx.coroutines.runBlocking
@ -33,6 +34,6 @@ class ShieldFundsSample {
// wallet.shieldFunds()
Twig.clip("ShieldFundsSample")
Assert.assertEquals(5, wallet.synchronizer.saplingBalances.value.availableZatoshi)
Assert.assertEquals(Zatoshi(5), wallet.synchronizer.saplingBalances.value?.available)
}
}

View File

@ -3,6 +3,7 @@ package cash.z.ecc.android.sdk.sample
import androidx.test.filters.LargeTest
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.type.ZcashNetwork.Testnet
import cash.z.ecc.android.sdk.util.TestWallet
import kotlinx.coroutines.delay
@ -18,7 +19,7 @@ import org.junit.Test
*/
class TransparentRestoreSample {
val TX_VALUE = ZcashSdk.MINERS_FEE_ZATOSHI / 2
val TX_VALUE = Zatoshi(ZcashSdk.MINERS_FEE.value / 2)
// val walletA = SimpleWallet(SEED_PHRASE, "WalletA")
@ -58,8 +59,8 @@ class TransparentRestoreSample {
val tbalance = wallet.transparentBalance()
val address = wallet.transparentAddress
twig("t-avail: ${tbalance.availableZatoshi} t-total: ${tbalance.totalZatoshi}")
Assert.assertTrue("Not enough funds to run sample. Expected some Zatoshi but found ${tbalance.availableZatoshi}. Try adding funds to $address", tbalance.availableZatoshi > 0)
twig("t-avail: ${tbalance.available} t-total: ${tbalance.total}")
Assert.assertTrue("Not enough funds to run sample. Expected some Zatoshi but found ${tbalance.available}. Try adding funds to $address", tbalance.available.value > 0)
twig("Shielding available transparent funds!")
// wallet.shieldFunds()

View File

@ -10,6 +10,7 @@ import cash.z.ecc.android.sdk.db.entity.isPending
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.internal.service.LightWalletGrpcService
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.tool.DerivationTool
import cash.z.ecc.android.sdk.type.WalletBalance
import cash.z.ecc.android.sdk.type.ZcashNetwork
@ -70,7 +71,7 @@ class TestWallet(
val synchronizer: SdkSynchronizer = Synchronizer.newBlocking(initializer) as SdkSynchronizer
val service = (synchronizer.processor.downloader.lightWalletService as LightWalletGrpcService)
val available get() = synchronizer.saplingBalances.value.availableZatoshi
val available get() = synchronizer.saplingBalances.value?.available
val shieldedAddress =
runBlocking { DerivationTool.deriveShieldedAddress(seed, network = network) }
val transparentAddress =
@ -105,7 +106,7 @@ class TestWallet(
return this
}
suspend fun send(address: String = transparentAddress, memo: String = "", amount: Long = 500L, fromAccountIndex: Int = 0): TestWallet {
suspend fun send(address: String = transparentAddress, memo: String = "", amount: Zatoshi = Zatoshi(500L), fromAccountIndex: Int = 0): TestWallet {
Twig.sprout("$alias sending")
synchronizer.sendToAddress(shieldedSpendingKey, amount, address, memo, fromAccountIndex)
.takeWhile { it.isPending() }
@ -128,9 +129,9 @@ class TestWallet(
}
synchronizer.getTransparentBalance(transparentAddress).let { walletBalance ->
twig("FOUND utxo balance of total: ${walletBalance.totalZatoshi} available: ${walletBalance.availableZatoshi}")
twig("FOUND utxo balance of total: ${walletBalance.total} available: ${walletBalance.available}")
if (walletBalance.availableZatoshi > 0L) {
if (walletBalance.available.value > 0L) {
synchronizer.shieldFunds(shieldedSpendingKey, transparentSecretKey)
.onCompletion { twig("done shielding funds") }
.catch { twig("Failed with $it") }

View File

@ -46,6 +46,7 @@ import cash.z.ecc.android.sdk.internal.transaction.TransactionRepository
import cash.z.ecc.android.sdk.internal.transaction.WalletTransactionEncoder
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.internal.twigTask
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.tool.DerivationTool
import cash.z.ecc.android.sdk.type.AddressType
import cash.z.ecc.android.sdk.type.AddressType.Shielded
@ -101,9 +102,9 @@ class SdkSynchronizer internal constructor(
) : Synchronizer {
// pools
private val _orchardBalances = MutableStateFlow(WalletBalance())
private val _saplingBalances = MutableStateFlow(WalletBalance())
private val _transparentBalances = MutableStateFlow(WalletBalance())
private val _orchardBalances = MutableStateFlow<WalletBalance?>(null)
private val _saplingBalances = MutableStateFlow<WalletBalance?>(null)
private val _transparentBalances = MutableStateFlow<WalletBalance?>(null)
private val _status = ConflatedBroadcastChannel<Synchronizer.Status>(DISCONNECTED)
@ -636,14 +637,14 @@ class SdkSynchronizer internal constructor(
override fun sendToAddress(
spendingKey: String,
zatoshi: Long,
amount: Zatoshi,
toAddress: String,
memo: String,
fromAccountIndex: Int
): Flow<PendingTransaction> = flow {
twig("Initializing pending transaction")
// Emit the placeholder transaction, then switch to monitoring the database
txManager.initSpend(zatoshi, toAddress, memo, fromAccountIndex).let { placeHolderTx ->
txManager.initSpend(amount, toAddress, memo, fromAccountIndex).let { placeHolderTx ->
emit(placeHolderTx)
txManager.encode(spendingKey, placeHolderTx).let { encodedTx ->
// only submit if it wasn't cancelled. Otherwise cleanup, immediately for best UX.
@ -675,7 +676,7 @@ class SdkSynchronizer internal constructor(
val zAddr = getAddress(0)
// Emit the placeholder transaction, then switch to monitoring the database
txManager.initSpend(tBalance.availableZatoshi, zAddr, memo, 0).let { placeHolderTx ->
txManager.initSpend(tBalance.available, zAddr, memo, 0).let { placeHolderTx ->
emit(placeHolderTx)
txManager.encode(spendingKey, transparentSecretKey, placeHolderTx).let { encodedTx ->
// only submit if it wasn't cancelled. Otherwise cleanup, immediately for best UX.

View File

@ -4,6 +4,7 @@ import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.db.entity.ConfirmedTransaction
import cash.z.ecc.android.sdk.db.entity.PendingTransaction
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.type.AddressType
import cash.z.ecc.android.sdk.type.ConsensusMatchType
import cash.z.ecc.android.sdk.type.WalletBalance
@ -102,17 +103,17 @@ interface Synchronizer {
/**
* A stream of balance values for the orchard pool. Includes the available and total balance.
*/
val orchardBalances: StateFlow<WalletBalance>
val orchardBalances: StateFlow<WalletBalance?>
/**
* A stream of balance values for the sapling pool. Includes the available and total balance.
*/
val saplingBalances: StateFlow<WalletBalance>
val saplingBalances: StateFlow<WalletBalance?>
/**
* A stream of balance values for the transparent pool. Includes the available and total balance.
*/
val transparentBalances: StateFlow<WalletBalance>
val transparentBalances: StateFlow<WalletBalance?>
/* Transactions */
@ -203,7 +204,7 @@ interface Synchronizer {
*/
fun sendToAddress(
spendingKey: String,
zatoshi: Long,
amount: Zatoshi,
toAddress: String,
memo: String = "",
fromAccountIndex: Int = 0

View File

@ -5,6 +5,7 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import cash.z.ecc.android.sdk.model.Zatoshi
//
// Entities
@ -166,6 +167,7 @@ data class ConfirmedTransaction(
val expiryHeight: Int? = null,
override val raw: ByteArray? = byteArrayOf()
) : MinedTransaction, SignedTransaction {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ConfirmedTransaction) return false
@ -207,6 +209,9 @@ data class ConfirmedTransaction(
}
}
val ConfirmedTransaction.valueInZatoshi
get() = Zatoshi(value)
data class EncodedTransaction(val txId: ByteArray, override val raw: ByteArray, val expiryHeight: Int?) :
SignedTransaction {
override fun equals(other: Any?): Boolean {
@ -283,8 +288,7 @@ interface PendingTransaction : SignedTransaction, Transaction {
//
fun PendingTransaction.isSameTxId(other: MinedTransaction): Boolean {
return rawTransactionId != null && other.rawTransactionId != null &&
rawTransactionId!!.contentEquals(other.rawTransactionId)
return rawTransactionId != null && rawTransactionId!!.contentEquals(other.rawTransactionId)
}
fun PendingTransaction.isSameTxId(other: PendingTransaction): Boolean {

View File

@ -4,7 +4,7 @@ package cash.z.ecc.android.sdk.ext
import cash.z.ecc.android.sdk.ext.Conversions.USD_FORMATTER
import cash.z.ecc.android.sdk.ext.Conversions.ZEC_FORMATTER
import cash.z.ecc.android.sdk.ext.ZcashSdk.ZATOSHI_PER_ZEC
import cash.z.ecc.android.sdk.model.Zatoshi
import java.math.BigDecimal
import java.math.MathContext
import java.math.RoundingMode
@ -21,7 +21,7 @@ import java.util.Locale
// TODO: provide a dynamic way to configure this globally for the SDK
// For now, just make these vars so at least they could be modified in one place
object Conversions {
var ONE_ZEC_IN_ZATOSHI = BigDecimal(ZATOSHI_PER_ZEC, MathContext.DECIMAL128)
var ONE_ZEC_IN_ZATOSHI = BigDecimal(Zatoshi.ZATOSHI_PER_ZEC, MathContext.DECIMAL128)
var ZEC_FORMATTER = NumberFormat.getInstance(Locale.getDefault()).apply {
roundingMode = RoundingMode.HALF_EVEN
maximumFractionDigits = 6
@ -47,11 +47,11 @@ object Conversions {
* @return this Zatoshi value represented as ZEC, in a string with at least [minDecimals] and at
* most [maxDecimals]
*/
inline fun Long?.convertZatoshiToZecString(
inline fun Zatoshi?.convertZatoshiToZecString(
maxDecimals: Int = ZEC_FORMATTER.maximumFractionDigits,
minDecimals: Int = ZEC_FORMATTER.minimumFractionDigits
): String {
return currencyFormatter(maxDecimals, minDecimals).format(this.convertZatoshiToZec(maxDecimals))
return currencyFormatter(maxDecimals, minDecimals).format(convertZatoshiToZec(maxDecimals))
}
/**
@ -162,8 +162,8 @@ inline fun currencyFormatter(maxDecimals: Int, minDecimals: Int): NumberFormat {
* @return this Long Zatoshi value represented as ZEC using a BigDecimal with the given scale,
* rounded accurately out to 128 digits.
*/
inline fun Long?.convertZatoshiToZec(scale: Int = ZEC_FORMATTER.maximumFractionDigits): BigDecimal {
return BigDecimal(this ?: 0L, MathContext.DECIMAL128).divide(
inline fun Zatoshi?.convertZatoshiToZec(scale: Int = ZEC_FORMATTER.maximumFractionDigits): BigDecimal {
return BigDecimal(this?.value ?: 0L, MathContext.DECIMAL128).divide(
Conversions.ONE_ZEC_IN_ZATOSHI,
MathContext.DECIMAL128
).setScale(scale, ZEC_FORMATTER.roundingMode)
@ -176,15 +176,15 @@ inline fun Long?.convertZatoshiToZec(scale: Int = ZEC_FORMATTER.maximumFractionD
* @return this ZEC value represented as Zatoshi, rounded accurately out to 128 digits, in order to
* minimize cumulative errors when applied repeatedly over a sequence of calculations.
*/
inline fun BigDecimal?.convertZecToZatoshi(): Long {
if (this == null) return 0L
inline fun BigDecimal?.convertZecToZatoshi(): Zatoshi {
if (this == null) return Zatoshi(0L)
if (this < BigDecimal.ZERO) {
throw IllegalArgumentException(
"Invalid ZEC value: $this. ZEC is represented by notes and" +
" cannot be negative"
)
}
return this.multiply(Conversions.ONE_ZEC_IN_ZATOSHI, MathContext.DECIMAL128).toLong()
return Zatoshi(this.multiply(Conversions.ONE_ZEC_IN_ZATOSHI, MathContext.DECIMAL128).toLong())
}
/**
@ -214,7 +214,7 @@ inline fun Double?.toZec(decimals: Int = ZEC_FORMATTER.maximumFractionDigits): B
* @return this Double ZEC value converted into Zatoshi, with proper rounding and precision by
* leveraging an intermediate BigDecimal object.
*/
inline fun Double?.convertZecToZatoshi(decimals: Int = ZEC_FORMATTER.maximumFractionDigits): Long {
inline fun Double?.convertZecToZatoshi(decimals: Int = ZEC_FORMATTER.maximumFractionDigits): Zatoshi {
return this.toZec(decimals).convertZecToZatoshi()
}

View File

@ -1,5 +1,7 @@
package cash.z.ecc.android.sdk.ext
import cash.z.ecc.android.sdk.model.Zatoshi
/**
* Wrapper for all the constant values in the SDK. It is important that these values stay fixed for
* all users of the SDK. Otherwise, if individual wallet makers are using different values, it
@ -11,12 +13,7 @@ object ZcashSdk {
/**
* Miner's fee in zatoshi.
*/
val MINERS_FEE_ZATOSHI = 1_000L
/**
* The number of zatoshi that equal 1 ZEC.
*/
val ZATOSHI_PER_ZEC = 100_000_000L
val MINERS_FEE = Zatoshi(1_000L)
/**
* The theoretical maximum number of blocks in a reorg, due to other bottlenecks in the protocol design.

View File

@ -12,6 +12,7 @@ import cash.z.ecc.android.sdk.internal.db.PendingTransactionDao
import cash.z.ecc.android.sdk.internal.db.PendingTransactionDb
import cash.z.ecc.android.sdk.internal.service.LightWalletService
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.Zatoshi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.Flow
@ -70,14 +71,14 @@ class PersistentTransactionManager(
//
override suspend fun initSpend(
zatoshiValue: Long,
value: Zatoshi,
toAddress: String,
memo: String,
fromAccountIndex: Int
): PendingTransaction = withContext(Dispatchers.IO) {
twig("constructing a placeholder transaction")
var tx = PendingTransactionEntity(
value = zatoshiValue,
value = value.value,
toAddress = toAddress,
memo = memo.toByteArray(),
accountIndex = fromAccountIndex

View File

@ -1,6 +1,7 @@
package cash.z.ecc.android.sdk.internal.transaction
import cash.z.ecc.android.sdk.db.entity.PendingTransaction
import cash.z.ecc.android.sdk.model.Zatoshi
import kotlinx.coroutines.flow.Flow
/**
@ -21,7 +22,7 @@ interface OutboundTransactionManager {
* @return the associated pending transaction whose ID can be used to monitor for changes.
*/
suspend fun initSpend(
zatoshi: Long,
zatoshi: Zatoshi,
toAddress: String,
memo: String,
fromAccountIndex: Int

View File

@ -6,6 +6,7 @@ import cash.z.ecc.android.sdk.ext.ZcashSdk.SPEND_PARAM_FILE_NAME
import cash.z.ecc.android.sdk.internal.SdkDispatchers
import cash.z.ecc.android.sdk.internal.ext.deleteSuspend
import cash.z.ecc.android.sdk.internal.twig
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.tool.DerivationTool
import cash.z.ecc.android.sdk.type.UnifiedViewingKey
import cash.z.ecc.android.sdk.type.WalletBalance
@ -100,36 +101,51 @@ class RustBackend private constructor() : RustBackendWelding {
}
}
override suspend fun getShieldedAddress(account: Int) = withContext(SdkDispatchers.DATABASE_IO) {
getShieldedAddress(
pathDataDb,
account,
networkId = network.id
)
}
override suspend fun getShieldedAddress(account: Int) =
withContext(SdkDispatchers.DATABASE_IO) {
getShieldedAddress(
pathDataDb,
account,
networkId = network.id
)
}
override suspend fun getTransparentAddress(account: Int, index: Int): String {
throw NotImplementedError("TODO: implement this at the zcash_client_sqlite level. But for now, use DerivationTool, instead to derive addresses from seeds")
}
override suspend fun getBalance(account: Int) = withContext(SdkDispatchers.DATABASE_IO) {
getBalance(
pathDataDb,
account,
networkId = network.id
)
override suspend fun getBalance(account: Int): Zatoshi {
val longValue = withContext(SdkDispatchers.DATABASE_IO) {
getBalance(
pathDataDb,
account,
networkId = network.id
)
}
return Zatoshi(longValue)
}
override suspend fun getVerifiedBalance(account: Int) = withContext(SdkDispatchers.DATABASE_IO) {
getVerifiedBalance(
pathDataDb,
account,
networkId = network.id
)
override suspend fun getVerifiedBalance(account: Int): Zatoshi {
val longValue = withContext(SdkDispatchers.DATABASE_IO) {
getVerifiedBalance(
pathDataDb,
account,
networkId = network.id
)
}
return Zatoshi(longValue)
}
override suspend fun getReceivedMemoAsUtf8(idNote: Long) =
withContext(SdkDispatchers.DATABASE_IO) { getReceivedMemoAsUtf8(pathDataDb, idNote, networkId = network.id) }
withContext(SdkDispatchers.DATABASE_IO) {
getReceivedMemoAsUtf8(
pathDataDb,
idNote,
networkId = network.id
)
}
override suspend fun getSentMemoAsUtf8(idNote: Long) = withContext(SdkDispatchers.DATABASE_IO) {
getSentMemoAsUtf8(
@ -147,13 +163,14 @@ class RustBackend private constructor() : RustBackendWelding {
)
}
override suspend fun getNearestRewindHeight(height: Int): Int = withContext(SdkDispatchers.DATABASE_IO) {
getNearestRewindHeight(
pathDataDb,
height,
networkId = network.id
)
}
override suspend fun getNearestRewindHeight(height: Int): Int =
withContext(SdkDispatchers.DATABASE_IO) {
getNearestRewindHeight(
pathDataDb,
height,
networkId = network.id
)
}
/**
* Deletes data for all blocks above the given height. Boils down to:
@ -161,7 +178,13 @@ class RustBackend private constructor() : RustBackendWelding {
* DELETE FROM blocks WHERE height > ?
*/
override suspend fun rewindToHeight(height: Int) =
withContext(SdkDispatchers.DATABASE_IO) { rewindToHeight(pathDataDb, height, networkId = network.id) }
withContext(SdkDispatchers.DATABASE_IO) {
rewindToHeight(
pathDataDb,
height,
networkId = network.id
)
}
override suspend fun scanBlocks(limit: Int): Boolean {
return if (limit > 0) {
@ -184,13 +207,14 @@ class RustBackend private constructor() : RustBackendWelding {
}
}
override suspend fun decryptAndStoreTransaction(tx: ByteArray) = withContext(SdkDispatchers.DATABASE_IO) {
decryptAndStoreTransaction(
pathDataDb,
tx,
networkId = network.id
)
}
override suspend fun decryptAndStoreTransaction(tx: ByteArray) =
withContext(SdkDispatchers.DATABASE_IO) {
decryptAndStoreTransaction(
pathDataDb,
tx,
networkId = network.id
)
}
override suspend fun createToAddress(
consensusBranchId: Long,
@ -281,7 +305,7 @@ class RustBackend private constructor() : RustBackendWelding {
networkId = network.id
)
}
return WalletBalance(total, verified)
return WalletBalance(Zatoshi(total), Zatoshi(verified))
}
override fun isValidShieldedAddr(addr: String) =

View File

@ -1,5 +1,6 @@
package cash.z.ecc.android.sdk.jni
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.type.UnifiedViewingKey
import cash.z.ecc.android.sdk.type.WalletBalance
import cash.z.ecc.android.sdk.type.ZcashNetwork
@ -47,7 +48,7 @@ interface RustBackendWelding {
suspend fun getTransparentAddress(account: Int = 0, index: Int = 0): String
suspend fun getBalance(account: Int = 0): Long
suspend fun getBalance(account: Int = 0): Zatoshi
fun getBranchIdForHeight(height: Int): Long
@ -55,7 +56,7 @@ interface RustBackendWelding {
suspend fun getSentMemoAsUtf8(idNote: Long): String
suspend fun getVerifiedBalance(account: Int = 0): Long
suspend fun getVerifiedBalance(account: Int = 0): Zatoshi
// fun parseTransactionDataList(tdl: LocalRpcTypes.TransactionDataList): LocalRpcTypes.TransparentTransactionList

View File

@ -0,0 +1,31 @@
package cash.z.ecc.android.sdk.model
/**
* A unit of currency used throughout the SDK.
*
* End users (e.g. app users) generally are not shown Zatoshi values. Instead they are presented
* with ZEC, which is a decimal value represented only as a String. ZEC are not used internally,
* to avoid floating point imprecision.
*/
data class Zatoshi(val value: Long) {
init {
require(value >= MIN_INCLUSIVE) { "Zatoshi must be in the range [$MIN_INCLUSIVE, $MAX_INCLUSIVE]" }
require(value <= MAX_INCLUSIVE) { "Zatoshi must be in the range [$MIN_INCLUSIVE, $MAX_INCLUSIVE]" }
}
operator fun plus(other: Zatoshi) = Zatoshi(value + other.value)
operator fun minus(other: Zatoshi) = Zatoshi(value - other.value)
companion object {
/**
* The number of Zatoshi that equal 1 ZEC.
*/
const val ZATOSHI_PER_ZEC = 100_000_000L
private const val MAX_ZEC_SUPPLY = 21_000_000
const val MIN_INCLUSIVE = 0
const val MAX_INCLUSIVE = ZATOSHI_PER_ZEC * MAX_ZEC_SUPPLY
}
}

View File

@ -1,37 +1,32 @@
package cash.z.ecc.android.sdk.type
import cash.z.ecc.android.sdk.model.Zatoshi
/**
* Data structure to hold the total and available balance of the wallet. This is what is
* received on the balance channel.
*
* @param totalZatoshi the total balance, ignoring funds that cannot be used.
* @param availableZatoshi the amount of funds that are available for use. Typical reasons that funds
* @param total the total balance, ignoring funds that cannot be used.
* @param available the amount of funds that are available for use. Typical reasons that funds
* may be unavailable include fairly new transactions that do not have enough confirmations or
* notes that are tied up because we are awaiting change from a transaction. When a note has
* been spent, its change cannot be used until there are enough confirmations.
*/
data class WalletBalance(
val totalZatoshi: Long = -1,
val availableZatoshi: Long = -1
val total: Zatoshi,
val available: Zatoshi
) {
val pendingZatoshi = totalZatoshi.coerceAtLeast(0) - availableZatoshi.coerceAtLeast(0)
operator fun plus(other: WalletBalance): WalletBalance {
return if (
totalZatoshi == -1L && other.totalZatoshi == -1L &&
availableZatoshi == -1L && other.availableZatoshi == -1L
) {
// if everything is uninitialized, then return the same
WalletBalance(-1L, -1L)
} else {
// otherwise, ignore any uninitialized values
WalletBalance(
totalZatoshi = totalZatoshi.coerceAtLeast(0) + other.totalZatoshi.coerceAtLeast(0),
availableZatoshi = availableZatoshi.coerceAtLeast(0) + other.availableZatoshi.coerceAtLeast(
0
)
)
}
init {
require(total.value >= available.value) { "Wallet total balance must be >= available balance" }
}
val pending = total - available
operator fun plus(other: WalletBalance): WalletBalance =
WalletBalance(
total + other.total,
available + other.available
)
}
/**
@ -76,7 +71,13 @@ interface UnifiedAddress {
val rawTransparentAddress: String
}
enum class ZcashNetwork(val id: Int, val networkName: String, val saplingActivationHeight: Int, val defaultHost: String, val defaultPort: Int) {
enum class ZcashNetwork(
val id: Int,
val networkName: String,
val saplingActivationHeight: Int,
val defaultHost: String,
val defaultPort: Int
) {
Testnet(0, "testnet", 280_000, "testnet.lightwalletd.com", 9067),
Mainnet(1, "mainnet", 419_200, "mainnet.lightwalletd.com", 9067);

View File

@ -0,0 +1,38 @@
package cash.z.ecc.android.sdk.model
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class ZatoshiTest {
@Test
fun minValue() {
assertFailsWith<IllegalArgumentException> {
Zatoshi(Zatoshi.MIN_INCLUSIVE - 1L)
}
}
@Test
fun maxValue() {
assertFailsWith<IllegalArgumentException> {
Zatoshi(Zatoshi.MAX_INCLUSIVE + 1)
}
}
@Test
fun plus() {
assertEquals(Zatoshi(4), Zatoshi(1) + Zatoshi(3))
}
@Test
fun minus() {
assertEquals(Zatoshi(3), Zatoshi(4) - Zatoshi(1))
}
@Test
fun minus_fail() {
assertFailsWith<IllegalArgumentException> {
Zatoshi(5) - Zatoshi(6)
}
}
}

View File

@ -161,6 +161,7 @@ dependencyResolutionManagement {
library("junit-api", "org.junit.jupiter:junit-jupiter-api:$junitVersion")
library("junit-engine", "org.junit.jupiter:junit-jupiter-engine:$junitVersion")
library("junit-migration", "org.junit.jupiter:junit-jupiter-migrationsupport:$junitVersion")
library("kotlin-test", "org.jetbrains.kotlin:kotlin-test:$kotlinVersion")
library("kotlinx-coroutines-test", "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinxCoroutinesVersion")
library("mockito-android", "org.mockito:mockito-android:$mockitoVersion")
library("mockito-junit", "org.mockito:mockito-junit-jupiter:$mockitoVersion")