Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b43375a
Update zanoKit, use new lib methods for transactions
omurovch Jul 14, 2026
fbe1a88
Update version data (0.49.2)
omurovch Jul 15, 2026
e93913c
Update Zano kit, show friendly error when node unreachable
omurovch Jul 16, 2026
0b9e9c9
Translate new strings
Jul 16, 2026
0f61cea
Update version data (0.49.3)
omurovch Jul 22, 2026
d886b52
Update tronKit
omurovch Jul 22, 2026
1f8ec0a
Update Zcash SDK for Ironwood network upgrade
omurovch Jul 25, 2026
5871e45
Show Zcash migration required row after Ironwood activation
omurovch Jul 25, 2026
4cf2936
Show Zcash migration alert on balance tab startup
omurovch Jul 25, 2026
9340cd1
Add Zcash Ironwood migration confirmation page
omurovch Jul 25, 2026
cf52ad3
Execute Zcash Ironwood migration and label its transactions
omurovch Jul 25, 2026
5fe063d
Implement immediate Zcash migration as full-balance self-send
omurovch Jul 25, 2026
9835592
Fix migration transaction labeling after mining
omurovch Jul 25, 2026
d8238f3
Pin Zcash SDK fork build with the engine merged into librustzcash
omurovch Jul 25, 2026
a1cc89c
Hide Zcash migration alerts until the wallet is fully synced
omurovch Jul 25, 2026
575c5d2
Adopt Zcash SDK 2.7.0-rc.2 with the official migration API
omurovch Jul 26, 2026
85c88a2
Update version data (0.49.4)
omurovch Jul 27, 2026
590cd07
Translate new strings
Jul 27, 2026
54a6ee8
Trigger Zcash migration alerts from the spendable balance only
omurovch Jul 27, 2026
9cf2de9
Record every submitted Zcash migration transaction id
omurovch Jul 27, 2026
c788535
Mask Zcash migration amount when balances are hidden
omurovch Jul 27, 2026
25cb3e3
Run migration result side effects in LaunchedEffect
omurovch Jul 27, 2026
8c871e1
Move Zcash migration view model to ViewModelUiState
omurovch Jul 27, 2026
587f2df
Merge branch 'master' into version/0.50
omurovch Aug 12, 2026
7b665b8
Match the migration confirmation layout to the 0.49 release
omurovch Aug 12, 2026
07c374c
Show n/a in the migration fee cell while the proposal loads
omurovch Aug 12, 2026
6c30f33
Gate the Migrate button on the proposal and move effects
omurovch Aug 12, 2026
4be8110
Resolve the failure message during composition
omurovch Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ android {
applicationId = "io.horizontalsystems.bankwallet"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.compileSdk.get().toInt()
versionCode = 173
versionCode = 176
versionName = "0.50.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ marketKit = "c045ed4"
solanaKit = "b487161"
tronKit = "6fc27cf"
thorchainKit = "9950fce"
zcashSdk = "2.5.2"
zcashSdk = "2.7.0-rc.2"

# Binance
binanceConnector = "3.4.1"
Expand Down
12 changes: 11 additions & 1 deletion translation_snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@
"Balance_SyncedUntil": "until %s",
"Balance_Syncing": "Syncing…",
"Balance_Title": "Balance",
"Balance_Zcash_MigrationRequired": "Migration Required",
"Balance_Zcash_Migration_Description": "Zcash network upgrade:\\nMove your ZEC from Orchard to the new Ironwood pool. Your funds are safe.",
"Balance_Zcash_Migration_Migrate": "Migrate",
"Balance_Zcash_Migration_Title": "Migration to Ironwood",
"Balance_Zcash_ShieldedDescription": "Use for personal and private transfers between wallets.",
"Balance_Zcash_Transparent": "Transparent",
"Balance_Zcash_TransparentDescription": "Use for receiving from exchanges or services that don’t support shielded addresses.",
Expand Down Expand Up @@ -1085,6 +1089,7 @@
"Send_Error_LowFee_Description": "Fee Rate is too low.",
"Send_Error_MaximumAmount": "Max. amount %s",
"Send_Error_MinimumAmount": "Min. amount %s",
"Send_Error_NodeUnreachable": "Could not reach the node. Please check your connection and try again.",
"Send_Error_SendToSelf": "Sending %s to your own address is not allowed. Please enter a different recipient address.",
"Send_Error_UnsupportedAddress": "Time locking works only when sending to payment addresses starting with 1... (aka BIP44 addresses)",
"Send_Fee": "Fee",
Expand Down Expand Up @@ -1472,6 +1477,8 @@
"Transactions_Filter_StablecoinDustAmount_Description": "Hides small-value incoming transfers (e.g. USDT below $1.5). These transactions are often used for phishing and address-poisoning attacks.",
"Transactions_From": "from %s",
"Transactions_Incoming": "Received",
"Transactions_Migrate": "Migrate",
"Transactions_Migrate_ToIronwood": "to Ironwood",
"Transactions_Mint": "Minted",
"Transactions_Multiple": "Multiple",
"Transactions_OperationType": "Operation Type",
Expand Down Expand Up @@ -1590,6 +1597,9 @@
"WidgetList_Config_Title": "Choose Type",
"Widget_EnableAutostartWarning": "To make sure that this widget works properly, you should enable Autostart.",
"ZanoNodeSettings_Description": "Choose the node Unstoppable app should use for synchronizing wallet(s).",
"ZcashEndpointSettings_Description": "Choose the lightwallet server Unstoppable app should use for synchronizing Zcash wallet(s)."
"ZcashEndpointSettings_Description": "Choose the lightwallet server Unstoppable app should use for synchronizing Zcash wallet(s).",
"Zcash_Migration_Confirm_Title": "Confirm Migration",
"Zcash_Migration_PubliclyVisible_Description": "Moving funds between pools requires the migrated amount to be publicly visible on the blockchain. The sender, recipient and your addresses remain private.",
"Zcash_Migration_PubliclyVisible_Title": "Publicly Visible Amount"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import io.horizontalsystems.zanokit.NodeUnreachableException
import java.net.UnknownHostException

class SendZanoViewModel(
Expand Down Expand Up @@ -206,6 +207,7 @@ class SendZanoViewModel(

private fun createCaution(error: Throwable) = when (error) {
is UnknownHostException -> HSCaution(TranslatableString.ResString(R.string.Hud_Text_NoInternet))
is NodeUnreachableException -> HSCaution(TranslatableString.ResString(R.string.Send_Error_NodeUnreachable))
is LocalizedException -> HSCaution(TranslatableString.ResString(error.errorTextRes))
else -> HSCaution(TranslatableString.PlainString(error.message ?: ""))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import io.horizontalsystems.walletkit.modules.send.zcash.SendZCashModule
import io.horizontalsystems.walletkit.modules.send.zcash.SendZCashScreen
import io.horizontalsystems.walletkit.modules.send.zcash.SendZCashViewModel
import io.horizontalsystems.walletkit.modules.send.zcash.shield.ShieldZcashPage
import io.horizontalsystems.walletkit.modules.zcashmigration.ZcashMigrationPage
import io.horizontalsystems.walletkit.modules.zcashnetwork.ZcashNetworkPage
import io.horizontalsystems.marketkit.models.BlockchainType
import io.horizontalsystems.marketkit.models.Token
Expand Down Expand Up @@ -136,6 +137,12 @@ class ZcashChainPlugin(

override fun unshieldedBalanceThreshold(): BigDecimal = ZcashAdapter.minimalShieldThreshold

override fun migrationRequiredBalance(wallet: Wallet): BigDecimal? =
App.adapterManager.getAdapterForWallet<ZcashAdapter>(wallet)?.ironwoodMigrationRequiredBalance

override fun migrationPage(wallet: Wallet, entryPoint: KClass<out HSPage>): HSPage =
ZcashMigrationPage(ZcashMigrationPage.Input(wallet, entryPoint))

override suspend fun swapDestinationAddress(account: Account): String =
cachedAddress(account.id, transparentAddressCache, transparentAddressMutex) {
ZcashAdapter.getTransparentAddress(account, endpointManager().currentEndpoint.toLightWalletEndpoint())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import io.horizontalsystems.walletkit.entities.transactionrecords.bitcoin.Bitcoi
import io.horizontalsystems.walletkit.entities.transactionrecords.bitcoin.BitcoinOutgoingTransactionRecord
import io.horizontalsystems.walletkit.entities.transactionrecords.zcash.ZcashShieldingTransactionRecord
import io.horizontalsystems.walletkit.modules.transactions.FilterTransactionType
import io.horizontalsystems.walletkit.core.toRawHexString
import io.horizontalsystems.marketkit.models.BlockchainType
import io.horizontalsystems.marketkit.models.Token
import io.reactivex.BackpressureStrategy
Expand Down Expand Up @@ -81,6 +82,13 @@ class ZcashAdapter(
private val decimalCount = 8
private val network: ZcashNetwork = ZcashNetwork.Mainnet
private val feeChangeHeight: Long = 1_077_550
private val ironwoodActivationHeight: Long = when (network) {
ZcashNetwork.Testnet -> 4_134_000
else -> 3_428_143 // NU6.3 mainnet activation
}

private val appContext: Context = context.applicationContext
private var migrationProposal: Proposal? = null

private val synchronizer: CloseableSynchronizer
private val transactionsProvider: ZcashTransactionsProvider
Expand Down Expand Up @@ -122,6 +130,29 @@ class ZcashAdapter(

override var balanceData: BalanceData? = null

private var accountBalance: AccountBalance? = null

/**
* Orchard-pool balance that must be migrated to the Ironwood pool, or null while
* the network has not reached NU6.3 activation or there is nothing to migrate.
*/
val ironwoodMigrationRequiredBalance: BigDecimal?
get() {
// Only report when fully synced: mid-sync the Orchard balance is provisional
// (notes spent in not-yet-scanned blocks still count), so the migration
// alerts would show with a wrong amount
if (syncState != AdapterState.Synced) return null
val tip = synchronizer.latestHeight?.value ?: return null
if (tip < ironwoodActivationHeight) return null
val orchard = accountBalance?.orchard ?: return null
// Spendable balance only, matching what proposeIronwoodMigration can sweep:
// the all-or-nothing proposal fails while any Orchard note is still pending,
// and showing pending funds here would advertise an amount the confirmation
// screen cannot migrate
if (orchard.available.value <= 0) return null
return orchard.available.convertZatoshiToZec(decimalCount)
}

val statusInfo: Map<String, Any>
get() {
val statusInfo = LinkedHashMap<String, Any>()
Expand Down Expand Up @@ -188,7 +219,12 @@ class ZcashAdapter(
zcashAccount = runBlocking { synchronizer.getAccounts().first() }
receiveAddress = runBlocking { synchronizer.getUnifiedAddress(zcashAccount) }
receiveAddressTransparent = runBlocking { synchronizer.getTransparentAddress(zcashAccount) }
transactionsProvider = ZcashTransactionsProvider(zcashAccount.accountUuid, synchronizer as SdkSynchronizer)
transactionsProvider = ZcashTransactionsProvider(zcashAccount.accountUuid, synchronizer as SdkSynchronizer) { txHash ->
// Migration txids recorded at broadcast; match both hash orientations since
// TransferResult.Success txid endianness is not guaranteed
val migrationTxIds = localStorage.zcashMigrationTransactionIds
migrationTxIds.contains(txHash.toRawHexString()) || migrationTxIds.contains(txHash.toReversedHex())
}
synchronizer.onCriticalErrorHandler = { error ->
Log.e("ZcashAdapter", "Critical error", error)
true
Expand Down Expand Up @@ -293,6 +329,55 @@ class ZcashAdapter(
// }
}

/**
* Proposes an immediate full-balance Orchard -> Ironwood migration and returns the
* net migrated amount plus the implied fee (Orchard total minus migrated amount).
* The proposed schedule is retained for the subsequent execution step.
*/
/**
* Proposes the Orchard -> Ironwood migration through the SDK: a single all-or-nothing
* transaction sweeping the account's entire Orchard balance to its own internal
* receiver, with the fee computed so nothing is left in Orchard. The proposal is
* retained for [executeIronwoodMigration].
*/
suspend fun proposeIronwoodMigration(): IronwoodMigrationProposal {
val orchard = accountBalance?.orchard ?: throw IllegalStateException("Orchard balance is not loaded yet")
val availableZatoshi = orchard.available.value
if (availableZatoshi <= 0) {
throw IllegalStateException("No spendable Orchard balance yet, wait for the wallet to finish syncing")
}

val proposal = synchronizer.proposeOrchardToIronwoodMigration(zcashAccount)
migrationProposal = proposal

val feeZatoshi = proposal.totalFeeRequired().value
return IronwoodMigrationProposal(
amount = Zatoshi((availableZatoshi - feeZatoshi).coerceAtLeast(0)).convertZatoshiToZec(decimalCount),
fee = Zatoshi(feeZatoshi).convertZatoshiToZec(decimalCount)
)
}

data class IronwoodMigrationProposal(
val amount: BigDecimal,
val fee: BigDecimal
)

/**
* Signs and broadcasts the self-transfer proposal retained by [proposeIronwoodMigration]
* through the ordinary send path, records every submitted txid for transaction-history
* labeling, and returns the first one.
*/
suspend fun executeIronwoodMigration(): String {
val proposal = migrationProposal ?: throw IllegalStateException("Migration was not proposed")

val txHashes = send(proposal)
val txHash = txHashes.firstOrNull() ?: throw IllegalStateException("Migration send returned no transaction id")

migrationProposal = null
localStorage.zcashMigrationTransactionIds += txHashes.map { it.lowercase() }
return txHash
}

suspend fun sendShieldProposal() {
val shieldProposal = shieldProposal() ?: throw IllegalStateException("Couldn't create shield proposal")
send(shieldProposal)
Expand Down Expand Up @@ -326,16 +411,16 @@ class ZcashAdapter(
memo = memo
)

private suspend fun send(proposal: Proposal): String? {
private suspend fun send(proposal: Proposal): List<String> {
val spendingKey = DerivationTool.getInstance().deriveUnifiedSpendingKey(seed, network, Zip32AccountIndex.new(0))

try {
val results = synchronizer.createProposedTransactions(proposal, spendingKey).toList()
var firstTxHash: String? = null
val txHashes = mutableListOf<String>()
results.forEach { result ->
when (result) {
is TransactionSubmitResult.Success -> {
if (firstTxHash == null) firstTxHash = result.txIdString()
txHashes.add(result.txIdString())
}

is TransactionSubmitResult.Failure -> {
Expand All @@ -354,7 +439,7 @@ class ZcashAdapter(
}
}
}
return firstTxHash
return txHashes
} catch (e: IllegalArgumentException) {
throw IllegalArgumentException("Invalid proposal: ${e.message}", e)
} catch (e: Exception) {
Expand All @@ -371,7 +456,7 @@ class ZcashAdapter(
}

override suspend fun sendProposal(proposal: Proposal): String? {
return send(proposal)
return send(proposal).firstOrNull()
}

private fun createPaymentUri(outputs: List<TransferOutput>): String {
Expand Down Expand Up @@ -489,6 +574,7 @@ class ZcashAdapter(
}

private fun onBalance(balance: AccountBalance) {
accountBalance = balance
val balanceAvailable = balance.available.convertZatoshiToZec(decimalCount)
val balancePending = balance.pending.convertZatoshiToZec(decimalCount)
val balanceUnshielded = balance.unshielded.convertZatoshiToZec(decimalCount)
Expand Down Expand Up @@ -564,7 +650,7 @@ class ZcashAdapter(
showRawTransaction = false,
amount = transaction.value.convertZatoshiToZec(decimalCount).negate(),
to = transaction.recipients?.firstOrNull()?.addressValue,
sentToSelf = false,
sentToSelf = transaction.sentToSelf,
memo = transaction.memo,
source = wallet.transactionSource,
replaceable = false
Expand Down Expand Up @@ -709,7 +795,7 @@ object ZcashAddressValidator {
}

val AccountBalance.available: Zatoshi
get() = this.sapling.available + this.orchard.available
get() = this.sapling.available + this.orchard.available + this.ironwood.available

val AccountBalance.pending: Zatoshi
get() = this.sapling.pending + this.orchard.pending
get() = this.sapling.pending + this.orchard.pending + this.ironwood.pending
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package io.horizontalsystems.walletkit.core.adapters.zcash
import io.horizontalsystems.walletkit.entities.transactionrecords.zcash.ShieldDirection
import cash.z.ecc.android.sdk.model.AccountUuid
import cash.z.ecc.android.sdk.model.FirstClassByteArray
import cash.z.ecc.android.sdk.model.TransactionOutput
import cash.z.ecc.android.sdk.model.TransactionOverview
import cash.z.ecc.android.sdk.model.TransactionPool
import cash.z.ecc.android.sdk.model.TransactionRecipient
import cash.z.ecc.android.sdk.model.TransactionState
import cash.z.ecc.android.sdk.model.Zatoshi
Expand All @@ -23,8 +25,16 @@ class ZcashTransaction : Comparable<ZcashTransaction> {
val failed: Boolean
val isIncoming: Boolean
val shieldDirection: ShieldDirection?
val sentToSelf: Boolean

constructor(accountId: AccountUuid, confirmedTransaction: TransactionOverview, recipients: List<TransactionRecipient>?, memo: String?) {
constructor(
accountId: AccountUuid,
confirmedTransaction: TransactionOverview,
recipients: List<TransactionRecipient>?,
memo: String?,
isIronwoodMigration: Boolean,
outputs: List<TransactionOutput>
) {
confirmedTransaction.let {
val hasSpentAndReceived = it.totalSpent.value > 0 && it.totalReceived.value > 0

Expand All @@ -33,12 +43,28 @@ class ZcashTransaction : Comparable<ZcashTransaction> {
it.isSentTransaction &&
recipients.all { recipient -> recipient.accountUuid == accountId }

if (it.isShielding || internalTransaction) {
shieldDirection = if (it.isShielding) ShieldDirection.Shield else ShieldDirection.Unshield
// Unshielding is a self-transfer with a transparent output; a shielded
// self-transfer is a migration only when its txid was recorded at broadcast,
// otherwise it is an ordinary send to own address. A recorded migration txid
// classifies on its own: after the transaction is mined and re-scanned from
// the chain, recipients are no longer reported as the wallet's own account,
// so the internal-transaction heuristic stops matching.
val isUnshielding = internalTransaction &&
outputs.any { output -> output.pool == TransactionPool.TRANSPARENT }

if (it.isShielding || internalTransaction || isIronwoodMigration) {
shieldDirection = when {
it.isShielding -> ShieldDirection.Shield
isIronwoodMigration -> ShieldDirection.MigrateToIronwood
isUnshielding -> ShieldDirection.Unshield
else -> null
}
sentToSelf = shieldDirection == null
feePaid = it.feePaid
value = it.totalReceived
} else {
shieldDirection = null
sentToSelf = false
feePaid = it.feePaid
value = it.netValue
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import kotlin.math.min

class ZcashTransactionsProvider(
private val accountUuid: AccountUuid,
private val synchronizer: SdkSynchronizer
private val synchronizer: SdkSynchronizer,
private val isMigrationTransaction: (txHash: ByteArray) -> Boolean
) {
private val mutex = Mutex()
private var transactions = listOf<ZcashTransaction>()
Expand All @@ -41,7 +42,12 @@ class ZcashTransactionsProvider(
null
}
val memo = synchronizer.getMemos(it).firstOrNull()
ZcashTransaction(accountUuid, it, recipients, memo)
val outputs = if (it.isSentTransaction) {
synchronizer.getTransactionOutputs(it)
} else {
emptyList()
}
ZcashTransaction(accountUuid, it, recipients, memo, isMigrationTransaction(it.txId.value.byteArray), outputs)
}
newTransactionsSubject.onNext(newZcashTransactions)
val notUpdatedTransactions =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package io.horizontalsystems.walletkit.modules.zcashmigration

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.horizontalsystems.walletkit.core.App
import io.horizontalsystems.walletkit.core.adapters.zcash.ZcashAdapter
import io.horizontalsystems.walletkit.entities.Wallet
import io.horizontalsystems.walletkit.modules.xrate.XRateService

object ZcashMigrationModule {

class Factory(
private val wallet: Wallet,
) : ViewModelProvider.Factory {
val adapter = App.adapterManager.getAdapterForWallet<ZcashAdapter>(wallet)
?: throw IllegalStateException("ZcashAdapter is null")

@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val xRateService = XRateService(App.marketKit, App.currencyManager.baseCurrency)

return ZcashMigrationViewModel(
adapter,
wallet,
xRateService
) as T
}
}

}
Loading
Loading