[#776] Enable ZIP 317 fees support

* [#776] Enable ZIP 317 fees support

* Deprecate ZcashSdk.MINERS_FEE

* Replace MINERS_FEE with Proposal API in Demo app

* Changelog update

* Bump SDK to 2.0.8 to produce snapshot version
This commit is contained in:
Honza Rychnovský 2024-03-13 10:03:53 +01:00 committed by GitHub
parent 5b04cbc579
commit 078e76a941
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 131 additions and 35 deletions

View File

@ -6,6 +6,10 @@ and this library adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- The SDK uses ZIP-317 fee system internally
- `ZcashSdk.MINERS_FEE` has been deprecated, and will be removed in 2.1.0
## [2.0.7] - 2024-03-08
### Fixed

View File

@ -376,7 +376,7 @@ class RustBackend private constructor(
*/
companion object {
internal val rustLibraryLoader = NativeLibraryLoader("zcashwalletsdk")
private const val IS_USE_ZIP_317_FEES = false
private const val IS_USE_ZIP_317_FEES = true
suspend fun loadLibrary() {
rustLibraryLoader.load {

View File

@ -40,6 +40,7 @@ import cash.z.ecc.android.sdk.demoapp.ui.screen.transactions.view.Transactions
import cash.z.ecc.android.sdk.demoapp.util.AndroidApiVersion
import cash.z.ecc.android.sdk.demoapp.util.fromResources
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.model.Proposal
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.type.ServerValidation
import kotlinx.coroutines.CoroutineScope
@ -131,6 +132,9 @@ internal fun ComposeActivity.Navigation() {
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
val walletSnapshot = walletViewModel.walletSnapshot.collectAsStateWithLifecycle().value
val spendingKey = walletViewModel.spendingKey.collectAsStateWithLifecycle().value
val sendTransactionProposal = remember { mutableStateOf<Proposal?>(null) }
if (null == synchronizer || null == walletSnapshot || null == spendingKey) {
// Display loading indicator
} else {
@ -140,10 +144,14 @@ internal fun ComposeActivity.Navigation() {
onSend = {
walletViewModel.send(it)
},
onGetProposal = {
sendTransactionProposal.value = walletViewModel.getSendProposal(it)
},
onBack = {
walletViewModel.clearSendOrShieldState()
navController.popBackStackJustOnce(SEND)
}
},
sendTransactionProposal = sendTransactionProposal.value
)
}
}

View File

@ -14,7 +14,6 @@ import cash.z.ecc.android.sdk.demoapp.databinding.FragmentGetBalanceBinding
import cash.z.ecc.android.sdk.demoapp.ext.requireApplicationContext
import cash.z.ecc.android.sdk.demoapp.util.SyncBlockchainBenchmarkTrace
import cash.z.ecc.android.sdk.demoapp.util.fromResources
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.model.Account
@ -138,10 +137,10 @@ class GetBalanceFragment : BaseDemoFragment<FragmentGetBalanceBinding>() {
}
binding.shield.apply {
// TODO [#776]: Support variable fees
// TODO [#776]: https://github.com/zcash/zcash-android-wallet-sdk/issues/776
// This check is not entirely correct - it does not calculate the resulting fee with the new Proposal API
// Note that the entire fragment-based old Demo app will be removed as part of [#973]
visibility =
if ((transparentBalance ?: Zatoshi(0)) > ZcashSdk.MINERS_FEE) {
if ((transparentBalance ?: Zatoshi(0)).value > 0L) {
View.VISIBLE
} else {
View.GONE

View File

@ -26,12 +26,11 @@ import cash.z.ecc.android.sdk.demoapp.R
import cash.z.ecc.android.sdk.demoapp.fixture.WalletSnapshotFixture
import cash.z.ecc.android.sdk.demoapp.ui.screen.home.viewmodel.SendState
import cash.z.ecc.android.sdk.demoapp.ui.screen.home.viewmodel.WalletSnapshot
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.model.toZecString
@Preview(name = "Balance")
@Composable
@Suppress("ktlint:standard:function-naming")
@Composable
private fun ComposablePreview() {
MaterialTheme {
Balance(
@ -152,10 +151,8 @@ private fun BalanceMainContent(
)
)
// TODO [#776]: Support variable fees
// TODO [#776]: https://github.com/zcash/zcash-android-wallet-sdk/issues/776
// This check will not be correct with variable fees
if (walletSnapshot.transparentBalance > ZcashSdk.MINERS_FEE) {
// This check is not entirely correct - it does not calculate the resulting fee with the new Proposal API
if (walletSnapshot.transparentBalance.value > 0L) {
// Note this implementation does not guard against multiple clicks
Button(onClick = onShieldFunds) {
Text(stringResource(id = R.string.action_shield))

View File

@ -2,7 +2,6 @@ package cash.z.ecc.android.sdk.demoapp.ui.screen.home.viewmodel
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.model.PercentDecimal
import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.ecc.android.sdk.model.Zatoshi
@ -16,13 +15,11 @@ data class WalletSnapshot(
val progress: PercentDecimal,
val synchronizerError: SynchronizerError?
) {
// TODO [#776]: Support variable fees
// TODO [#776]: https://github.com/zcash/zcash-android-wallet-sdk/issues/776
// Note: the wallet is effectively empty if it cannot cover the miner's fee
val hasFunds =
saplingBalance.available.value >
(ZcashSdk.MINERS_FEE.value.toDouble() / Zatoshi.ZATOSHI_PER_ZEC) // 0.0001
val hasSaplingBalance = saplingBalance.total.value > 0
// This check is not entirely correct - it does not calculate the resulting fee with the new Proposal API
val hasFunds = saplingBalance.available.value > 0L
val hasSaplingBalance = saplingBalance.total.value > 0L
val isSendEnabled: Boolean get() = status == Synchronizer.Status.SYNCED && hasFunds
}

View File

@ -22,12 +22,14 @@ import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.PercentDecimal
import cash.z.ecc.android.sdk.model.PersistableWallet
import cash.z.ecc.android.sdk.model.Proposal
import cash.z.ecc.android.sdk.model.TransactionSubmitResult
import cash.z.ecc.android.sdk.model.WalletAddresses
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.proposeSend
import cash.z.ecc.android.sdk.model.send
import cash.z.ecc.android.sdk.tool.DerivationTool
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
@ -50,6 +52,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@ -57,6 +60,7 @@ import kotlin.time.Duration.Companion.seconds
// To make this more multiplatform compatible, we need to remove the dependency on Context
// for loading the preferences.
@Suppress("TooManyFunctions")
class WalletViewModel(application: Application) : AndroidViewModel(application) {
private val walletCoordinator = WalletCoordinator.getInstance(application)
@ -212,12 +216,36 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
}
}
/**
* Synchronously provides proposal object for the given [spendingKey] and [zecSend] objects
*/
fun getSendProposal(zecSend: ZecSend): Proposal? {
if (sendState.value is SendState.Sending) {
return null
}
val synchronizer = synchronizer.value
return if (null != synchronizer) {
// Calling the proposal API within a blocking coroutine should be fine for the showcase purpose
runBlocking {
val spendingKey = spendingKey.filterNotNull().first()
kotlin.runCatching {
synchronizer.proposeSend(spendingKey, zecSend)
}.onFailure {
Twig.error(it) { "Failed to get transaction proposal" }
}.getOrNull()
}
} else {
error("Unable to send funds because synchronizer is not loaded.")
}
}
/**
* Asynchronously shields transparent funds. Note that two shielding operations cannot occur at the same time.
*
* Observe the result via [sendState].
*/
@Suppress("MagicNumber")
fun shieldFunds() {
if (sendState.value is SendState.Sending) {
return
@ -230,6 +258,7 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
viewModelScope.launch {
val spendingKey = spendingKey.filterNotNull().first()
kotlin.runCatching {
@Suppress("MagicNumber")
synchronizer.proposeShielding(spendingKey.account, Zatoshi(100000))?.let {
synchronizer.createProposedTransactions(
it,

View File

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
@ -43,6 +44,7 @@ import cash.z.ecc.android.sdk.demoapp.util.fromResources
import cash.z.ecc.android.sdk.fixture.WalletFixture
import cash.z.ecc.android.sdk.model.Memo
import cash.z.ecc.android.sdk.model.MonetarySeparators
import cash.z.ecc.android.sdk.model.Proposal
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.model.ZecSend
import cash.z.ecc.android.sdk.model.ZecSendExt
@ -60,18 +62,22 @@ private fun ComposablePreview() {
walletSnapshot = WalletSnapshotFixture.new(),
sendState = SendState.None,
onSend = {},
onBack = {}
onGetProposal = {},
onBack = {},
sendTransactionProposal = null
)
}
}
@Composable
@Suppress("ktlint:standard:function-naming")
@Suppress("ktlint:standard:function-naming", "LongParameterList")
fun Send(
walletSnapshot: WalletSnapshot,
sendState: SendState,
onSend: (ZecSend) -> Unit,
onBack: () -> Unit
onGetProposal: (ZecSend) -> Unit,
onBack: () -> Unit,
sendTransactionProposal: Proposal?,
) {
Scaffold(topBar = {
SendTopAppBar(onBack)
@ -80,7 +86,9 @@ fun Send(
paddingValues = paddingValues,
walletSnapshot = walletSnapshot,
sendState = sendState,
onSend = onSend
onSend = onSend,
onGetProposal = onGetProposal,
sendTransactionProposal = sendTransactionProposal
)
}
}
@ -105,12 +113,14 @@ private fun SendTopAppBar(onBack: () -> Unit) {
}
@Composable
@Suppress("LongMethod", "ktlint:standard:function-naming")
@Suppress("LongMethod", "ktlint:standard:function-naming", "LongParameterList")
private fun SendMainContent(
paddingValues: PaddingValues,
walletSnapshot: WalletSnapshot,
sendState: SendState,
onSend: (ZecSend) -> Unit
onSend: (ZecSend) -> Unit,
onGetProposal: (ZecSend) -> Unit,
sendTransactionProposal: Proposal?,
) {
val context = LocalContext.current
val monetarySeparators = MonetarySeparators.current(locale = Locale.US)
@ -229,6 +239,36 @@ private fun SendMainContent(
Text(validation.joinToString(", "))
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
val zecSendValidation =
ZecSendExt.new(
context,
recipientAddressString,
amountZecString,
memoString,
monetarySeparators
)
when (zecSendValidation) {
is ZecSendExt.ZecSendValidation.Valid -> onGetProposal(zecSendValidation.zecSend)
is ZecSendExt.ZecSendValidation.Invalid -> validation = zecSendValidation.validationErrors
}
},
// Needs actual validation
enabled = amountZecString.isNotBlank() && recipientAddressString.isNotBlank()
) {
Text(stringResource(id = R.string.send_proposal_button))
}
if (sendTransactionProposal != null) {
Text(stringResource(id = R.string.send_proposal_status, sendTransactionProposal.toPrettyString()))
Spacer(modifier = Modifier.height(16.dp))
}
Button(
onClick = {
val zecSendValidation =

View File

@ -73,6 +73,9 @@
<string name="send_memo">Memo</string>
<string name="send_button">Send</string>
<string name="send_proposal_button">Get Proposal</string>
<string name="send_proposal_status">Proposal:\n<xliff:g id="proposal" example="Fee:0.001...">%1$s</xliff:g></string>
<string name="server_textfield_value"><xliff:g id="host" example="example.com">%1$s</xliff:g>:<xliff:g id="port" example="508">%2$d</xliff:g></string>
<string name="server_textfield_hint">&lt;host&gt;:&lt;port&gt;</string>
<string name="server_textfield_error">Invalid server</string>

View File

@ -26,7 +26,7 @@ ZCASH_ASCII_GPG_KEY=
# Configures whether release is an unstable snapshot, therefore published to the snapshot repository.
IS_SNAPSHOT=true
LIBRARY_VERSION=2.0.7
LIBRARY_VERSION=2.0.8
# Kotlin compiler warnings can be considered errors, failing the build.
ZCASH_IS_TREAT_WARNINGS_AS_ERRORS=true

View File

@ -18,3 +18,13 @@ suspend fun Synchronizer.send(
),
spendingKey
)
suspend fun Synchronizer.proposeSend(
spendingKey: UnifiedSpendingKey,
send: ZecSend
) = proposeTransfer(
spendingKey.account,
send.destination.address,
send.amount,
send.memo.value
)

View File

@ -5,7 +5,6 @@ import androidx.test.platform.app.InstrumentationRegistry
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.Synchronizer.Status.SYNCED
import cash.z.ecc.android.sdk.WalletInitMode
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.ext.onFirst
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.model.Account
@ -116,7 +115,7 @@ class TestnetIntegrationTest : ScopedTest() {
synchronizer.proposeTransfer(
spendingKey.account,
toAddress,
ZcashSdk.MINERS_FEE,
Zatoshi(10_000L),
"first mainnet tx from the SDK"
),
spendingKey

View File

@ -3,7 +3,6 @@
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.model.BlockHeight
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZcashNetwork
@ -20,7 +19,7 @@ import org.junit.Test
* the same data.
*/
class TransparentRestoreSample {
val txValue = Zatoshi(ZcashSdk.MINERS_FEE.value / 2)
val txValue = Zatoshi(Zatoshi(10_000L).value / 2)
// val walletA = SimpleWallet(SEED_PHRASE, "WalletA")

View File

@ -8,11 +8,19 @@ import cash.z.ecc.android.sdk.model.Zatoshi
* becomes easier to reduce privacy by segmenting the anonymity set of users, particularly as it
* relates to network requests.
*/
@Suppress("MagicNumber")
object ZcashSdk {
/**
* Miner's fee in zatoshi.
*/
@Suppress("MagicNumber")
@Deprecated(
message = "Upcoming SDK 2.1 will create multiple transactions at once for some recipients.",
replaceWith =
ReplaceWith(
"proposeTransfer(usk.account, toAddress, amount, memo) OR " +
"proposeShielding(usk.account, shieldingThreshold, memo)"
)
)
val MINERS_FEE = Zatoshi(10_000L)
/**

View File

@ -1,12 +1,12 @@
package cash.z.ecc.android.sdk.fixture
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.FirstClassByteArray
import cash.z.ecc.android.sdk.model.TransactionOverview
import cash.z.ecc.android.sdk.model.TransactionState
import cash.z.ecc.android.sdk.model.Zatoshi
@Suppress("MagicNumber")
object TransactionOverviewFixture {
const val ID: Long = 1
val RAW_ID: FirstClassByteArray get() = FirstClassByteArray("rawId".toByteArray())
@ -16,9 +16,8 @@ object TransactionOverviewFixture {
val RAW: FirstClassByteArray get() = FirstClassByteArray("raw".toByteArray())
const val IS_SENT_TRANSACTION: Boolean = false
@Suppress("MagicNumber")
val NET_VALUE: Zatoshi = Zatoshi(10_000)
val FEE_PAID: Zatoshi = ZcashSdk.MINERS_FEE
val FEE_PAID: Zatoshi = Zatoshi(10_000)
const val IS_CHANGE: Boolean = false
const val RECEIVED_NOTE_COUNT: Int = 1
const val SENT_NOTE_COUNT: Int = 0

View File

@ -51,4 +51,8 @@ class Proposal(
fun totalFeeRequired(): Zatoshi {
return Zatoshi(inner.totalFeeRequired())
}
fun toPrettyString(): String {
return "Transaction count: ${transactionCount()}, Total fee required: ${totalFeeRequired()}"
}
}