From 84c624eec78f2caf0ab84c4fbc127507a50bfa6c Mon Sep 17 00:00:00 2001 From: peachbits Date: Thu, 6 Aug 2026 15:20:03 -0700 Subject: [PATCH 1/2] Report isExpired from the wallet's scan floor, not the network tip A resync rewinds the wallet to its birthday, which un-mines every stored transaction until the scan re-reaches its block. TransactionState.Expired compares an unmined transaction's expiry height against the live network tip, so during that window the entire history counts as expired and the app flashes every transaction as failed - Android only, since iOS reports the database's expired_unmined column instead of recomputing against the tip. Reach the database's own verdict from public API: unmined, expiry enabled, and the fully-scanned floor past the expiry window. The floor trails 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. The emitted-transaction tracking also records the computed verdict, since it can flip without any tracked SDK field changing: a transaction stuck unmined keeps its minedHeight and (tip-expired) transactionState while the scan floor crosses its expiry window. Without the extra trigger, a genuine expiry would only surface on the next subscribe. --- CHANGELOG.md | 2 + .../java/app/edge/rnzcash/RNZcashModule.kt | 47 +++++++++++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afb5bc6..2eff336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- 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. diff --git a/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt b/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt index 2809792..45e1a3f 100644 --- a/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt +++ b/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt @@ -39,6 +39,7 @@ class RNZcashModule( 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) @@ -126,19 +127,26 @@ class RNZcashModule( txList.forEach { tx -> val txId = tx.txId.txIdString() val previousState = emittedForAlias[txId] + val isExpired = isTxExpired(wallet, tx) - // Check if this is a new transaction or if minedHeight/transactionState changed + // Check if this is a new transaction or if minedHeight, + // transactionState, or the expired verdict changed. The + // expired verdict has its own trigger because it can flip + // on its own: the scan floor reaches a stuck transaction's + // expiry window without any tracked SDK field changing. val isNew = previousState == null val minedHeightChanged = previousState?.minedHeight != tx.minedHeight val stateChanged = previousState?.transactionState != tx.transactionState + val expiredChanged = previousState?.isExpired != isExpired - if (isNew || minedHeightChanged || stateChanged) { + if (isNew || minedHeightChanged || stateChanged || expiredChanged) { transactionsToEmit.add(tx) // Update our tracking emittedForAlias[txId] = EmittedTxState( minedHeight = tx.minedHeight, transactionState = tx.transactionState, + isExpired = isExpired, ) } } @@ -246,6 +254,38 @@ 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, @@ -259,7 +299,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() @@ -604,6 +644,7 @@ class RNZcashModule( EmittedTxState( minedHeight = tx.minedHeight, transactionState = tx.transactionState, + isExpired = isTxExpired(wallet, tx), ) } promise.resolve(null) From ae5aaa2b032fbfb8710340bb6893200be45f933f Mon Sep 17 00:00:00 2001 From: peachbits Date: Fri, 7 Aug 2026 14:46:35 -0700 Subject: [PATCH 2/2] Report transactions as the rescan finds them, not all at once The app empties its transaction list for a resync and rebuilds it from what we report, but both platforms sent the whole set straight back, so the list refilled before the rescan had scanned anything. Android did it twice. rescan() cleared the emitted-transaction tracking, so the next collector pass saw every transaction as new and reported the lot at their pre-rewind heights - 19 seconds before the rewind had even landed, describing nothing that had changed. The rewind then unmined every row, which read as a change and sent the same set again, this time as unmined, which is what left settled history looking pending. iOS did it once, explicitly: rescan re-sent allTransactions as soon as the rewind finished. Keep the tracking across a rescan instead of clearing it, and treat a transaction losing its mined height as the rewind undoing our own scan rather than news about the transaction. Tracking still follows it to the unmined state, so re-mining reads as a change and reports normally, which is how the list rebuilds one transaction at a time. Unmined transactions are the exception on both platforms. Scanning only discovers transactions in mined blocks, so nothing would bring back a send still waiting to be mined; those are re-reported so they survive the resync. iOS has to collect them before the rewind, since afterwards every transaction looks unmined. --- CHANGELOG.md | 1 + .../java/app/edge/rnzcash/RNZcashModule.kt | 85 ++++++++++++++++--- ios/RNZcash.swift | 10 ++- 3 files changed, 80 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eff336..9e20b08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 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) diff --git a/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt b/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt index 45e1a3f..6d2f77a 100644 --- a/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt +++ b/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt @@ -38,7 +38,6 @@ 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, ) @@ -129,23 +128,73 @@ class RNZcashModule( val previousState = emittedForAlias[txId] val isExpired = isTxExpired(wallet, tx) - // Check if this is a new transaction or if minedHeight, - // transactionState, or the expired verdict changed. The - // expired verdict has its own trigger because it can flip - // on its own: the scan floor reaches a stuck transaction's - // expiry window without any tracked SDK field changing. + // 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 val expiredChanged = previousState?.isExpired != isExpired - if (isNew || minedHeightChanged || stateChanged || expiredChanged) { - transactionsToEmit.add(tx) + // 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) // Update our tracking emittedForAlias[txId] = EmittedTxState( minedHeight = tx.minedHeight, - transactionState = tx.transactionState, isExpired = isExpired, ) } @@ -273,6 +322,7 @@ class RNZcashModule( * 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, @@ -332,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) @@ -643,7 +701,6 @@ class RNZcashModule( emittedForAlias[tx.txId.txIdString()] = EmittedTxState( minedHeight = tx.minedHeight, - transactionState = tx.transactionState, isExpired = isTxExpired(wallet, tx), ) } diff --git a/ios/RNZcash.swift b/ios/RNZcash.swift index 48e86df..825c463 100644 --- a/ios/RNZcash.swift +++ b/ios/RNZcash.swift @@ -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]