Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- changed: A rescan no longer re-reports the transactions it is about to re-find, on either platform. The app empties its own transaction list for a resync and rebuilds it from what we send, but both platforms immediately sent the whole set back — Android because the rewind changes every row and its emitted-transaction tracking was cleared, iOS because `rescan` explicitly re-sent `allTransactions` once the rewind finished. The list refilled instantly, at pre-rewind heights on Android and as unmined everywhere, so a resync appeared to do nothing and settled history was described as pending. Both platforms now stay quiet and report each transaction as the scan finds it again, and nothing is carried across a resync — not even a send still waiting to be mined, which would otherwise be a row the scan can never rediscover and that outlives every resync.
- fixed: Android no longer brands the entire transaction history as expired while a resync rescans the wallet. `isExpired` came from `TransactionState.Expired`, which compares an unmined transaction's expiry height against the live network tip — and a resync un-mines every transaction until the scan re-reaches its block, so the whole history flashed as failed in the app until the rescan completed. Android now reaches the same verdict the wallet database's `expired_unmined` column does, which is also the signal iOS reports: a transaction is expired only once the wallet's own contiguous scan has passed its expiry window without finding it mined.

## 0.13.1 (2026-08-02)

- added: `ironwoodAvailableZatoshi` / `ironwoodTotalZatoshi` on `BalanceEvent`, on both platforms (zero until NU6.3 activates); the deprecated summed fields now include the ironwood pool.
Expand Down
124 changes: 111 additions & 13 deletions android/src/main/java/app/edge/rnzcash/RNZcashModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class RNZcashModule(
// Data class to track what we've emitted for each transaction
private data class EmittedTxState(
val minedHeight: BlockHeight?,
val transactionState: TransactionState,
val isExpired: Boolean,
)

private val networks = mapOf("mainnet" to ZcashNetwork.Mainnet, "testnet" to ZcashNetwork.Testnet)
Expand Down Expand Up @@ -126,19 +126,76 @@ class RNZcashModule(
txList.forEach { tx ->
val txId = tx.txId.txIdString()
val previousState = emittedForAlias[txId]

// Check if this is a new transaction or if minedHeight/transactionState changed
val isExpired = isTxExpired(wallet, tx)

// Report a transaction when it is new to us, when it gains or
// loses a mined height, or when our expiry verdict changes.
// The expired verdict needs its own trigger because it can
// flip on its own: the scan floor reaches a stuck
// transaction's expiry window without any SDK field changing.
//
// Deliberately not keyed off `transactionState`. That is
// derived from the live network tip - the same thing
// `isTxExpired` exists to avoid - so during a rescan it turns
// Expired for transactions the scan simply has not reached
// yet, and using it here would report a send back into the
// list the app had just emptied. It never reaches JavaScript
// either; everything the app is told comes from the mined
// height and our own expiry verdict, which have their own
// triggers above.
//
// The expiry verdict is re-read here rather than driven by the
// scan floor, which can advance without this flow emitting. In
// practice the flow turns over every few seconds - it follows
// the network height as well as the transaction table, so a new
// block alone is enough - and the verdict is re-read on each
// pass, which bounds how long a newly expired send can read as
// pending. Driving it off the floor directly would tighten that
// window at the cost of firing this pass far more often during
// a scan, for a transaction state that is already terminal.
val isNew = previousState == null
val minedHeightChanged = previousState?.minedHeight != tx.minedHeight
val stateChanged = previousState?.transactionState != tx.transactionState

if (isNew || minedHeightChanged || stateChanged) {
transactionsToEmit.add(tx)
val expiredChanged = previousState?.isExpired != isExpired
Comment thread
cursor[bot] marked this conversation as resolved.

// A rewind undoes our own scan; it is not news about the
// transaction. Two of its side effects would otherwise read as
// changes worth reporting, and reporting either would refill
// the list the app empties for a resync:
//
// Losing a mined height. The transaction is still settled on
// chain and the scan will find it again, so describing it as
// pending in the meantime is wrong. Tracking still moves to the
// unmined state, so re-mining reads as a change and reports
// normally - that is how the list rebuilds.
//
// Losing an expired verdict. The rewind drops the scan floor
// back below the expiry window, so a transaction we had already
// called expired stops looking expired. That says nothing new
// either, and re-reporting it would put a failed send back in
// the list as pending. It is reported again once the scan floor
// climbs past its expiry, this time as a genuine expiry.
//
// A chain reorg unmines a transaction the same way, and is
// suppressed the same way, which is a deliberate trade. Telling
// the two apart needs a flag scoped to our own rewind, and the
// clear condition for it - "the scan has caught up again" - has
// no obvious answer. The cost of not telling them apart is
// bounded: a reorged transaction keeps reading as confirmed
// until it is mined again, which on this chain is usually the
// next few blocks, and one that never returns is reported as
// expired once the scan floor passes its expiry window.
val stillUnmined = tx.minedHeight == null
val unminedByRewind = previousState?.minedHeight != null && stillUnmined
val unexpiredByRewind =
previousState?.isExpired == true && !isExpired && stillUnmined

if (isNew || minedHeightChanged || expiredChanged) {
if (!unminedByRewind && !unexpiredByRewind) transactionsToEmit.add(tx)
Comment thread
cursor[bot] marked this conversation as resolved.
// Update our tracking
emittedForAlias[txId] =
EmittedTxState(
minedHeight = tx.minedHeight,
transactionState = tx.transactionState,
isExpired = isExpired,
)
}
}
Expand Down Expand Up @@ -246,6 +303,39 @@ class RNZcashModule(
}
}

/**
* Whether a transaction has expired without ever being mined - the same
* verdict the wallet database's `v_transactions.expired_unmined` column
* reaches, and the signal iOS reports as `isExpiredUmined`.
*
* This deliberately does NOT use `TransactionState.Expired`. That state
* compares an unmined transaction's expiry height against the live network
* tip, so while a rewound wallet rescans - a resync un-mines the entire
* history until the scan re-reaches each block - every historical
* transaction sits below the tip's expiry cutoff and gets branded expired,
* and the app flashes the whole wallet as failed.
*
* Comparing against [CompactBlockProcessor.fullyScannedHeight] instead
* mirrors the database's own rule: a transaction is expired only once the
* wallet's contiguous scan has passed its expiry window without finding it
* mined. The floor trails the database's `MAX(blocks.height)` while ranges
* scan out of order, so this is equal-or-more conservative than the DB
* flag and converges with it (and with iOS) once the wallet is synced.
*/

private fun isTxExpired(
wallet: SdkSynchronizer,
tx: TransactionOverview,
): Boolean {
if (tx.minedHeight != null) return false
val expiryHeight = tx.expiryHeight ?: return false
// An expiry height of 0 disables expiry:
if (expiryHeight.value == 0L) return false
val scanFloor: BlockHeight? = wallet.processor.fullyScannedHeight.value
if (scanFloor == null) return false
return expiryHeight.value <= scanFloor.value
}

private suspend fun parseTx(
wallet: SdkSynchronizer,
tx: TransactionOverview,
Expand All @@ -259,7 +349,7 @@ class RNZcashModule(
map.putInt("blockTimeInSeconds", tx.blockTimeEpochSeconds?.toInt() ?: 0)
map.putString("rawTransactionId", tx.txId.txIdString())
map.putBoolean("isShielding", tx.isShielding)
map.putBoolean("isExpired", tx.transactionState == TransactionState.Expired)
map.putBoolean("isExpired", isTxExpired(wallet, tx))
tx.raw
?.byteArray
?.toHex()
Expand Down Expand Up @@ -292,9 +382,17 @@ class RNZcashModule(
) {
val wallet = getWallet(alias)
moduleScope.launch {
// Clear emitted transactions tracking and starting block height for this alias
emittedTransactions[alias]?.clear()

// The emitted-transaction tracking is deliberately left alone. The app
// clears its own transaction list for a resync and rebuilds it from
// what we report, and everything we already consider reported stays
// absent until the scan finds it again - which is the point.
//
// Nothing is carried across, not even a send still waiting to be
// mined. Keeping one would mean re-reporting a transaction the scan
// will never rediscover, which is how a send that never confirms
// becomes a row that outlives every resync. Letting the synchronizer
// be the only thing that reintroduces a transaction keeps the list
// honest about what the wallet can actually see.
wallet.coroutineScope
.async {
wallet.rewindToNearestHeight(wallet.latestBirthdayHeight)
Expand Down Expand Up @@ -603,7 +701,7 @@ class RNZcashModule(
emittedForAlias[tx.txId.txIdString()] =
EmittedTxState(
minedHeight = tx.minedHeight,
transactionState = tx.transactionState,
isExpired = isTxExpired(wallet, tx),
)
}
promise.resolve(null)
Expand Down
10 changes: 8 additions & 2 deletions ios/RNZcash.swift
Original file line number Diff line number Diff line change
Expand Up @@ -453,8 +453,14 @@ class RNZcash: RCTEventEmitter {
wallet.cancellables.forEach { $0.cancel() }
try await wallet.synchronizer.start()
wallet.subscribe()
let txs = try await wallet.synchronizer.allTransactions()
wallet.emitTxs(transactions: txs)
// Nothing is reported here. The app clears its own transaction
// list for a resync and rebuilds it from what we send, so sending
// the set back would refill the list it had just emptied, at the
// heights it held before the rewind. The event stream reports each
// transaction as the scan finds it again, and that is the only
// thing that should reintroduce one - including a send still
// waiting to be mined, which the app sees again when it is mined
// rather than being carried across every resync.
let balances = try await wallet.synchronizer.getAccountsBalances()
if let accountUUID = wallet.accountUUID,
let accountBalance = balances[accountUUID]
Expand Down
Loading