Skip to content

Commit 7745b66

Browse files
committed
Update Zcash SDK to v2.7.0-rc.2(Ironwood) and implement fee recalculation for unified balances
1 parent 920520e commit 7745b66

5 files changed

Lines changed: 573 additions & 147 deletions

File tree

app/src/main/java/cash/p/terminal/core/adapters/zcash/ZcashAdapter.kt

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,11 @@ class ZcashAdapter(
172172
private val balanceCheckMutex = Mutex()
173173
private var importUfvkError : Throwable? = null
174174

175+
private val feeLock = Any()
176+
private var feeJob: Job? = null
177+
private var lastFeeSnapshot: AccountBalance? = null
178+
private var feeGeneration = 0L
179+
175180
companion object {
176181
private const val DECIMAL_COUNT = 8
177182
private val DATABASE_CORRUPTION_MESSAGES = listOf(
@@ -181,6 +186,13 @@ class ZcashAdapter(
181186
)
182187

183188
val MINERS_FEE = ZcashSdk.MINERS_FEE.convertZatoshiToZec(DECIMAL_COUNT)
189+
190+
// The fee probe steps down by MINERS_FEE (10,000 zat) per attempt. With funds spread
191+
// over several pools ZIP-317 charges per bundle and per input, so a near-max send
192+
// easily exceeds the former 4 steps (40,000 zat). 20 attempts cover fees up to
193+
// 200,000 zat; the probe only runs to the end in the rare case where the whole
194+
// balance cannot be sent at all.
195+
private const val FEE_PROBE_ATTEMPTS = 20
184196
}
185197

186198
init {
@@ -421,8 +433,8 @@ class ZcashAdapter(
421433
statusJob?.cancel()
422434
statusJob = scope.launch {
423435
synchronizer.status.collect {
424-
if (it == Synchronizer.Status.SYNCED && fee.value == MINERS_FEE) {
425-
calculateFee()
436+
if (it == Synchronizer.Status.SYNCED) {
437+
scheduleFeeRecalculation()
426438
}
427439
}
428440
}
@@ -534,7 +546,18 @@ class ZcashAdapter(
534546
valuePending = Zatoshi(0)
535547
)
536548

537-
AddressSpecType.Unified -> synchronizer.walletBalances.value?.get(zcashAccount?.accountUuid)?.orchard
549+
// After NU6.3 activation the turnstile forbids adding value to Orchard, so change
550+
// and payments to Orchard recipients are built in an Ironwood bundle. A unified
551+
// address therefore holds funds in both pools and its balance is their sum.
552+
AddressSpecType.Unified -> synchronizer.walletBalances.value?.get(zcashAccount?.accountUuid)
553+
?.let { accountBalance ->
554+
WalletBalance(
555+
available = accountBalance.orchard.available + accountBalance.ironwood.available,
556+
changePending = accountBalance.orchard.changePending +
557+
accountBalance.ironwood.changePending,
558+
valuePending = accountBalance.orchard.valuePending + accountBalance.ironwood.valuePending
559+
)
560+
}
538561
?: WalletBalance(Zatoshi(0), Zatoshi(0), Zatoshi(0))
539562
}
540563
}
@@ -609,22 +632,65 @@ class ZcashAdapter(
609632
private val _fee: MutableStateFlow<BigDecimal> = MutableStateFlow(MINERS_FEE)
610633
override val fee: StateFlow<BigDecimal> = _fee.asStateFlow()
611634

635+
/**
636+
* Restarts the fee calculation whenever the account balance changes.
637+
*
638+
* The marker is the whole [AccountBalance] rather than `available` alone: under ZIP-317 the
639+
* fee depends on which pools are involved, and after NU6.3 activation funds can move from
640+
* Orchard to Ironwood without changing the total. The snapshot is read under the lock
641+
* because this runs both from `onBalance` (main dispatcher) and from the status collector
642+
* (IO): otherwise an older call could overwrite the marker and cancel the calculation
643+
* started for the fresher balance.
644+
*
645+
* While the published fee is still the default one the snapshot is not enough to conclude
646+
* the fee is current — ZIP-317 also depends on the proposal target height, which changes at
647+
* NU6.3 activation without touching any balance field — so the calculation repeats on every
648+
* trigger until a real fee is known.
649+
*/
650+
private fun scheduleFeeRecalculation() {
651+
synchronized(feeLock) {
652+
val snapshot = synchronizer.walletBalances.value?.get(zcashAccount?.accountUuid)
653+
if (snapshot == lastFeeSnapshot && _fee.value != MINERS_FEE) return
654+
lastFeeSnapshot = snapshot
655+
val generation = ++feeGeneration
656+
val available = walletBalance.available
657+
feeJob?.cancel()
658+
feeJob = scope.launch {
659+
val calculated = calculateFee(available)
660+
synchronized(feeLock) {
661+
// The probe may have passed its last cancellation point and returned after a
662+
// fresher calculation already published its fee. Ownership is checked by
663+
// calculation number rather than by snapshot value: on an A -> B -> A balance
664+
// cycle the stale probe would match the snapshot again and publish an
665+
// outdated fee.
666+
if (feeGeneration != generation) return@launch
667+
if (calculated == null) {
668+
// The probe found no workable fee — clear the marker so the next balance
669+
// tick retries it instead of treating the fee as already calculated.
670+
lastFeeSnapshot = null
671+
} else {
672+
_fee.value = calculated
673+
}
674+
}
675+
}
676+
}
677+
}
678+
679+
/** Returns the discovered fee, or `null` when the probe failed outright. */
612680
private suspend fun calculateFee(
613681
balance: Zatoshi = walletBalance.available,
614-
tryCounter: Int = 4
615-
): Unit = withContext(dispatcherProvider.io) {
682+
tryCounter: Int = FEE_PROBE_ATTEMPTS
683+
): BigDecimal? = withContext(dispatcherProvider.io) {
616684
try {
617685
if (balance == Zatoshi(0)) {
618-
_fee.value = MINERS_FEE
619-
return@withContext
686+
return@withContext MINERS_FEE
620687
}
621-
val calculatedFee = synchronizer.proposeTransfer(
688+
synchronizer.proposeTransfer(
622689
account = getFirstAccount(),
623690
recipient = AppConfigProvider.donateAddresses[BlockchainType.Zcash]
624691
.orEmpty(),
625692
amount = balance
626-
).totalFeeRequired()
627-
_fee.value = calculatedFee.convertZatoshiToZec(DECIMAL_COUNT)
693+
).totalFeeRequired().convertZatoshiToZec(DECIMAL_COUNT)
628694
} catch (ex: Exception) {
629695
if (ex is TransactionEncoderException.ProposalFromParametersException && tryCounter > 0) {
630696
// Not enough money to send with commission
@@ -634,7 +700,10 @@ class ZcashAdapter(
634700
} catch (e: CancellationException) {
635701
throw e
636702
} catch (_: Throwable) {
703+
null
637704
}
705+
} else {
706+
null
638707
}
639708
}
640709
}
@@ -977,6 +1046,12 @@ class ZcashAdapter(
9771046
balance?.get(zcashAccount?.accountUuid)?.sapling?.let {
9781047
balanceUpdatedSubject.onNext(Unit)
9791048
}
1049+
// The pool composition changes at runtime: after NU6.3 activation change arrives in
1050+
// Ironwood and the available balance no longer matches a fee calculated for a single
1051+
// pool. Recalculate on every balance change, not only on the first sync.
1052+
if (syncState is AdapterState.Synced) {
1053+
scheduleFeeRecalculation()
1054+
}
9801055
startOneTimeAddressBalanceCheck()
9811056
}
9821057

app/src/test/java/cash/p/terminal/core/adapters/zcash/ZcashAdapterCorruptionRecoveryTest.kt

Lines changed: 7 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -1,155 +1,59 @@
11
package cash.p.terminal.core.adapters.zcash
22

3-
import android.content.Context
43
import android.database.sqlite.SQLiteDatabaseCorruptException
5-
import cash.p.terminal.core.ILocalStorage
64
import cash.p.terminal.core.TestDispatcherProvider
7-
import cash.p.terminal.core.managers.BackgroundKeepAliveManager
85
import cash.p.terminal.core.managers.RestoreSettings
9-
import cash.p.terminal.domain.usecase.ClearZCashWalletDataUseCase
10-
import cash.p.terminal.wallet.Account
11-
import cash.p.terminal.wallet.AccountOrigin
12-
import cash.p.terminal.wallet.AccountType
136
import cash.p.terminal.wallet.AdapterState
147
import cash.p.terminal.wallet.Wallet
158
import cash.z.ecc.android.sdk.SdkSynchronizer
169
import cash.z.ecc.android.sdk.Synchronizer
1710
import cash.z.ecc.android.sdk.WalletInitMode
1811
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor
1912
import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException
20-
import cash.z.ecc.android.sdk.model.AccountBalance
21-
import cash.z.ecc.android.sdk.model.AccountUuid
2213
import cash.z.ecc.android.sdk.model.BlockHeight
2314
import cash.z.ecc.android.sdk.model.PercentDecimal
2415
import cash.z.ecc.android.sdk.model.TransactionOverview
2516
import cash.z.ecc.android.sdk.model.ZcashNetwork
2617
import io.horizontalsystems.core.BackgroundManager
2718
import io.horizontalsystems.core.BackgroundManagerState
28-
import io.horizontalsystems.core.CoreApp
2919
import io.horizontalsystems.core.entities.BlockchainType
3020
import io.mockk.coEvery
3121
import io.mockk.coVerify
3222
import io.mockk.every
3323
import io.mockk.mockk
34-
import io.mockk.mockkObject
3524
import io.mockk.slot
36-
import io.mockk.unmockkAll
3725
import io.mockk.verify
3826
import junit.framework.TestCase.assertEquals
3927
import junit.framework.TestCase.assertFalse
4028
import junit.framework.TestCase.assertTrue
4129
import kotlinx.coroutines.CancellationException
4230
import kotlinx.coroutines.CompletableDeferred
43-
import kotlinx.coroutines.CoroutineScope
44-
import kotlinx.coroutines.Dispatchers
4531
import kotlinx.coroutines.ExperimentalCoroutinesApi
46-
import kotlinx.coroutines.SupervisorJob
4732
import kotlinx.coroutines.cancel
4833
import kotlinx.coroutines.flow.MutableStateFlow
4934
import kotlinx.coroutines.flow.flow
5035
import kotlinx.coroutines.suspendCancellableCoroutine
51-
import kotlinx.coroutines.test.StandardTestDispatcher
5236
import kotlinx.coroutines.test.advanceTimeBy
5337
import kotlinx.coroutines.test.advanceUntilIdle
54-
import kotlinx.coroutines.test.resetMain
5538
import kotlinx.coroutines.test.runCurrent
5639
import kotlinx.coroutines.test.runTest
57-
import kotlinx.coroutines.test.setMain
58-
import org.junit.After
59-
import org.junit.Before
6040
import org.junit.Test
61-
import org.koin.core.context.startKoin
62-
import org.koin.core.context.stopKoin
63-
import org.koin.dsl.module
6441

6542
/**
6643
* Tests for ZcashAdapter database corruption detection and recovery.
6744
*
68-
* All adapter coroutines (recovery, status/start/restart jobs, the subscriber scope) run on
69-
* [dispatcher], a single [StandardTestDispatcher] shared with `runTest`, so every wait below is
70-
* driven deterministically via the virtual-time scheduler instead of real timeouts.
45+
* The shared harness lives in [ZcashAdapterTestFixture]: all adapter coroutines (recovery,
46+
* status/start/restart jobs, the subscriber scope) run on its single `StandardTestDispatcher`
47+
* shared with `runTest`, so every wait below is driven deterministically via the virtual-time
48+
* scheduler instead of real timeouts.
7149
*/
7250
@OptIn(ExperimentalCoroutinesApi::class)
73-
class ZcashAdapterCorruptionRecoveryTest {
74-
75-
private val dispatcher = StandardTestDispatcher()
76-
77-
// Separate from the `runTest` scope on purpose: the adapter's subscriber collectors are
78-
// parented to synchronizer.coroutineScope (ZcashAdapter.subscribe()) and never complete on
79-
// their own. If they were children of the `runTest` scope, `runTest` would hang waiting for
80-
// them. They live in `appScope` instead, cancelled explicitly in tearDown().
81-
private val appScope = CoroutineScope(SupervisorJob() + dispatcher)
82-
83-
private val context = mockk<Context>(relaxed = true)
84-
private val wallet = mockk<Wallet>(relaxed = true)
85-
private val localStorage = mockk<ILocalStorage>(relaxed = true)
86-
private val backgroundManager = mockk<BackgroundManager>(relaxed = true)
87-
private val singleUseAddressManager = mockk<ZcashSingleUseAddressManager>(relaxed = true)
88-
private val clearZCashWalletDataUseCase = mockk<ClearZCashWalletDataUseCase>(relaxed = true)
89-
private val backgroundKeepAliveManager = mockk<BackgroundKeepAliveManager>(relaxed = true)
90-
private val restoreSettings = RestoreSettings().apply { birthdayHeight = 2000000L }
91-
92-
private lateinit var mockSynchronizer: SdkSynchronizer
93-
94-
private val statusFlow = MutableStateFlow(Synchronizer.Status.SYNCING)
95-
private val progressFlow = MutableStateFlow(PercentDecimal.ZERO_PERCENT)
96-
private val walletBalancesFlow = MutableStateFlow<Map<AccountUuid, AccountBalance>?>(null)
97-
private val processorInfoFlow = MutableStateFlow(
98-
CompactBlockProcessor.ProcessorInfo(null, null, null)
99-
)
100-
private val allTransactionsFlow = MutableStateFlow<List<TransactionOverview>>(emptyList())
51+
class ZcashAdapterCorruptionRecoveryTest : ZcashAdapterTestFixture() {
10152

10253
private var capturedProcessorErrorHandler: ((Throwable?) -> Boolean)? = null
10354
private var capturedCriticalErrorHandler: ((Throwable?) -> Boolean)? = null
10455

105-
private lateinit var adapter: ZcashAdapter
106-
107-
@Before
108-
fun setUp() {
109-
Dispatchers.setMain(dispatcher)
110-
CoreApp.instance = mockk(relaxed = true)
111-
112-
startKoin {
113-
modules(module {
114-
single { clearZCashWalletDataUseCase }
115-
single { backgroundKeepAliveManager }
116-
})
117-
}
118-
119-
val testSeed = ByteArray(64) { it.toByte() }
120-
val accountType = mockk<AccountType.Mnemonic>(relaxed = true) {
121-
every { seed } returns testSeed
122-
}
123-
val account = mockk<Account>(relaxed = true) {
124-
every { id } returns "test-account-id"
125-
every { name } returns "Test"
126-
every { type } returns accountType
127-
every { origin } returns AccountOrigin.Created
128-
}
129-
every { wallet.account } returns account
130-
every { localStorage.zcashAccountIds } returns setOf("test-account-id")
131-
every { localStorage.torEnabled } returns false
132-
every { backgroundManager.stateFlow } returns MutableStateFlow(BackgroundManagerState.Unknown)
133-
every { clearZCashWalletDataUseCase.getValidAliasFromAccountId(any(), any()) } returns "zcash_test"
134-
135-
mockkObject(BlockHeight.Companion)
136-
coEvery { BlockHeight.ofLatestCheckpoint(any(), any()) } returns BlockHeight.new(2500000L)
137-
138-
setupMockSynchronizer()
139-
mockSynchronizerCompanion()
140-
}
141-
142-
private fun setupMockSynchronizer() {
143-
mockSynchronizer = mockk<SdkSynchronizer>(relaxed = true) {
144-
every { status } returns statusFlow
145-
every { progress } returns progressFlow
146-
every { walletBalances } returns walletBalancesFlow
147-
every { processorInfo } returns processorInfoFlow
148-
every { allTransactions } returns allTransactionsFlow
149-
every { coroutineScope } returns appScope
150-
every { latestHeight } returns null
151-
}
152-
56+
override fun stubSynchronizer() {
15357
val processorSlot = slot<(Throwable?) -> Boolean>()
15458
every { mockSynchronizer.onProcessorErrorHandler = capture(processorSlot) } answers {
15559
capturedProcessorErrorHandler = processorSlot.captured
@@ -160,17 +64,8 @@ class ZcashAdapterCorruptionRecoveryTest {
16064
}
16165
}
16266

163-
private fun mockSynchronizerCompanion() {
164-
mockkObject(Synchronizer)
67+
override fun stubSynchronizerCompanion() {
16568
coEvery { Synchronizer.erase(any(), any(), any()) } returns true
166-
every {
167-
Synchronizer.newBlocking(
168-
context = any(), zcashNetwork = any(), alias = any(),
169-
lightWalletEndpoint = any(), birthday = any(), walletInitMode = any(),
170-
setup = any(), isTorEnabled = any(), isExchangeRateEnabled = any()
171-
)
172-
} returns mockSynchronizer
173-
17469
coEvery {
17570
Synchronizer.new(
17671
context = any(), zcashNetwork = any(), alias = any(),
@@ -180,19 +75,6 @@ class ZcashAdapterCorruptionRecoveryTest {
18075
} returns mockSynchronizer
18176
}
18277

183-
private fun createAdapter(): ZcashAdapter {
184-
return ZcashAdapter(
185-
context = context,
186-
wallet = wallet,
187-
restoreSettings = restoreSettings,
188-
addressSpecTyped = null,
189-
localStorage = localStorage,
190-
backgroundManager = backgroundManager,
191-
singleUseAddressManager = singleUseAddressManager,
192-
dispatcherProvider = TestDispatcherProvider(dispatcher, appScope),
193-
)
194-
}
195-
19678
private fun createAdapter(baseDelayMs: Long, maxDelayMs: Long): ZcashAdapter {
19779
return ZcashAdapter(
19880
context, wallet, restoreSettings, null, localStorage, backgroundManager,
@@ -201,17 +83,6 @@ class ZcashAdapterCorruptionRecoveryTest {
20183
)
20284
}
20385

204-
@After
205-
fun tearDown() {
206-
if (::adapter.isInitialized) {
207-
adapter.stop()
208-
}
209-
appScope.cancel()
210-
stopKoin()
211-
Dispatchers.resetMain()
212-
unmockkAll()
213-
}
214-
21586
// --- onProcessorErrorHandler ---
21687

21788
@Test

0 commit comments

Comments
 (0)