Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ android {
applicationId = "io.horizontalsystems.bankwallet"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.compileSdk.get().toInt()
versionCode = 174
versionName = "0.49.3"
versionCode = 175
versionName = "0.49.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

resourceConfigurations += listOf("de", "es", "en", "fa", "fr", "ko", "pt", "pt-rBR", "ru", "tr", "zh")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ interface ILocalStorage {
var marketSearchRecentCoinUids: List<String>
var swapRecentTokenQueryIds: List<String>
var zcashAccountIds: Set<String>
var zcashMigrationTransactionIds: Set<String>
var autoLockInterval: AutoLockInterval
var chartIndicatorsEnabled: Boolean
var amountInputType: AmountInputType?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import io.horizontalsystems.bankwallet.entities.transactionrecords.bitcoin.Bitco
import io.horizontalsystems.bankwallet.entities.transactionrecords.zcash.ZcashShieldingTransactionRecord
import io.horizontalsystems.bankwallet.modules.transactions.FilterTransactionType
import io.horizontalsystems.bitcoincore.extensions.toReversedHex
import io.horizontalsystems.bankwallet.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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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())
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 @@ -479,6 +564,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 @@ -554,7 +640,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 @@ -699,7 +785,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 @@ -2,7 +2,9 @@ package io.horizontalsystems.bankwallet.core.adapters.zcash

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 @@ -22,8 +24,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 @@ -32,12 +42,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 Expand Up @@ -87,7 +113,7 @@ class ZcashTransaction : Comparable<ZcashTransaction> {
}

enum class ShieldDirection {
Shield, Unshield
Shield, Unshield, MigrateToIronwood
}

}
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
Expand Up @@ -163,6 +163,12 @@ class LocalStorageManager(
preferences.edit().putStringSet("zcashAccountIds", value).apply()
}

override var zcashMigrationTransactionIds: Set<String>
get() = preferences.getStringSet("zcashMigrationTransactionIds", setOf()) ?: setOf()
set(value) {
preferences.edit().putStringSet("zcashMigrationTransactionIds", value).apply()
}

override var baseCurrencyCode: String?
get() = preferences.getString(BASE_CURRENCY_CODE, null)
set(value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,15 @@ class ZcashShieldingTransactionRecord(

enum class Direction(val title: Int, val icon: Int) {
Shield(R.string.Transactions_Shield, R.drawable.ic_shield_24),
Unshield(R.string.Transactions_Unshield, R.drawable.ic_shield_off_24);
Unshield(R.string.Transactions_Unshield, R.drawable.ic_shield_off_24),
MigrateToIronwood(R.string.Transactions_Migrate, R.drawable.ic_migrate_24);

companion object {
fun from(wrapperDirection: ShieldDirection): Direction {
return when (wrapperDirection) {
ShieldDirection.Shield -> Shield
ShieldDirection.Unshield -> Unshield
ShieldDirection.MigrateToIronwood -> MigrateToIronwood
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import io.horizontalsystems.bankwallet.core.AdapterState
import io.horizontalsystems.bankwallet.core.App
import io.horizontalsystems.bankwallet.core.IAdapterManager
import io.horizontalsystems.bankwallet.core.ILocalStorage
import io.horizontalsystems.bankwallet.core.adapters.zcash.ZcashAdapter
import io.horizontalsystems.bankwallet.core.ViewModelUiState
import io.horizontalsystems.bankwallet.core.managers.BalanceHiddenManager
import io.horizontalsystems.bankwallet.core.managers.PriceManager
Expand Down Expand Up @@ -63,6 +64,8 @@ class BalanceViewModel(
private var balanceTabButtonsEnabled = localStorage.balanceTabButtonsEnabled
private var balanceHidden = balanceHiddenManager.balanceHiddenFlow.value
private var amountRoundingEnabled = localStorage.amountRoundingEnabledFlow.value
private var zcashMigrationAlertWallet: Wallet? = null
private var zcashMigrationAlertShown = false
private var totalUiState = createTotalUiState(totalService.stateFlow.value)

private val sortTypes =
Expand Down Expand Up @@ -90,6 +93,7 @@ class BalanceViewModel(

balanceItems = items

checkZcashMigrationRequired(items)
refreshViewItems()
}
}
Expand Down Expand Up @@ -194,9 +198,31 @@ class BalanceViewModel(
it.loading
},
balanceHidden = balanceHidden,
totalUiState = totalUiState
totalUiState = totalUiState,
zcashMigrationAlertWallet = zcashMigrationAlertWallet
)

private fun checkZcashMigrationRequired(items: List<BalanceModule.BalanceItem>?) {
if (zcashMigrationAlertShown) return
val zcashWallet = items
?.firstOrNull { it.wallet.token.blockchainType == BlockchainType.Zcash }
?.wallet ?: return
// BackupRequiredAlert fires for accounts with funds and no backup — let it win;
// the migration alert fires on a later emission once the account is backed up.
if (!zcashWallet.account.hasAnyBackup) return
adapterManager.getAdapterForWallet<ZcashAdapter>(zcashWallet)
?.ironwoodMigrationRequiredBalance ?: return

zcashMigrationAlertShown = true
zcashMigrationAlertWallet = zcashWallet
emitState()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

fun zcashMigrationAlertHandled() {
zcashMigrationAlertWallet = null
emitState()
}

private fun handleUpdatedBalanceViewType(balanceViewType: BalanceViewType) {
this.balanceViewType = balanceViewType

Expand Down Expand Up @@ -432,7 +458,8 @@ data class BalanceUiState(
val networkAvailable: Boolean,
val loading: Boolean,
val balanceHidden: Boolean,
val totalUiState: TotalUIState
val totalUiState: TotalUIState,
val zcashMigrationAlertWallet: Wallet? = null
)

data class OpenSendTokenSelect(
Expand Down
Loading
Loading