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

## Unreleased

- added: `ironwoodAvailableZatoshi` / `ironwoodTotalZatoshi` on `BalanceEvent`, on both platforms (zero until NU6.3 activates); the deprecated summed fields now include the ironwood pool.
- added: Orchard -> Ironwood (NU6.3) migration surface, identical on both platforms. `Synchronizer.proposeOrchardToIronwoodMigration` builds the sweep: the SDK spends every Orchard note to the wallet's own address with the fee chosen so no Orchard change remains, leaving Sapling and transparent funds untouched, and the app broadcasts it through the ordinary `createTransfer` pipeline. `Tools.getIronwoodActivationHeight` answers from consensus constants (ZIP 258), which neither SDK exposes. There is no migration state to poll: whether to offer the sweep follows from the activation height, the wallet being synced, and the Orchard balance, and broadcasting it spends those notes.
- changed: Pinned the Swift SDK to 2.7.0-rc.4, the release confirmed production-ready for Ironwood (NU6.3), and dropped the Edge-hosted one-time FFI build it replaces - the release ships its own `libzcashlc.xcframework.zip`, which `update-sources` now downloads and verifies against the checksum the SDK's own `Package.swift` declares.
- changed: Bumped zcash-android-sdk (and the incubator) from 2.5.2 to 2.7.0-rc.4. It ships Kotlin 2.3 metadata, which the app already provides.
- fixed: A transaction that settled while nothing was listening is reported again on the next `subscribe`. The native event stream only carries transactions that are newly found or newly mined, and native drops events entirely until JavaScript attaches a listener, so a transaction mined while the app was closed - or during a failed sync - was neither on the next launch and was never reported again: it stayed at height 0, "pending", forever. `Synchronizer.subscribe` now asks native for the current transaction set once its listeners are attached, which is the only point at which delivery is guaranteed. Re-sending known transactions is harmless, since only those whose height or amount changed are updated.
- fixed: Checkpoint generation now carries the Ironwood commitment tree. `TreeState.ironwoodTree` (field 7) was missing from the bundled lightwalletd proto, so `update-checkpoints` would have silently dropped it and produced post-NU6.3 checkpoints with no Ironwood tree state — the same defect a post-NU5 checkpoint missing `orchardTree` has. Pre-activation output is unchanged (the field comes back empty and is stripped, exactly like `orchardTree` before NU5), so existing checkpoints need no regeneration.

## 0.12.1 (2026-06-18)

- changed: Updated checkpoints
Expand Down
4 changes: 2 additions & 2 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ dependencies {

implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.paging:paging-runtime-ktx:2.1.2'
implementation 'cash.z.ecc.android:zcash-android-sdk:2.5.2'
implementation 'cash.z.ecc.android:zcash-android-sdk-incubator:2.5.2'
implementation 'cash.z.ecc.android:zcash-android-sdk:2.7.0-rc.4'
implementation 'cash.z.ecc.android:zcash-android-sdk-incubator:2.7.0-rc.4'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3"
}
91 changes: 91 additions & 0 deletions android/src/main/java/app/edge/rnzcash/IronwoodMigration.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package app.edge.rnzcash

import cash.z.ecc.android.sdk.SdkSynchronizer
import cash.z.ecc.android.sdk.model.ZcashNetwork
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import java.util.Base64

/** Raised when a migration proposal cannot be quoted. */
class IronwoodMigrationException(
message: String,
) : Exception(message)

/**
* Orchard -> Ironwood (NU6.3) support.
*
* The sweep is one ordinary proposal the app broadcasts through the normal
* `createTransfer` pipeline, mirroring the iOS bridge method for method,
* because the JS API is the cross-platform contract.
*/
object IronwoodMigration {
/**
* NU6.3 activation heights, from ZIP 258 (final). Hardcoded for the same
* reason `ZcashNetwork` hardcodes its Sapling and Orchard activation
* heights: they are consensus constants, and neither SDK exposes an
* Ironwood accessor. Replace if one ever appears.
*/
private const val MAINNET_NU6_3_ACTIVATION_HEIGHT = 3_428_143L
private const val TESTNET_NU6_3_ACTIVATION_HEIGHT = 4_134_000L

/**
* The NU6.3 activation height for the network, or null when it has none —
* including a custom/darkside network, which carries its own heights.
*/
fun ironwoodActivationHeight(network: ZcashNetwork): Long? =
when {
network.isMainnet() -> MAINNET_NU6_3_ACTIVATION_HEIGHT
network.isTestnet() -> TESTNET_NU6_3_ACTIVATION_HEIGHT
else -> null
}

/**
* The Orchard-only sweep proposal, shaped as the JS
* `ImmediateMigrationProposal` (`{ amountZatoshi, feeZatoshi, proposalBase64 }`).
*/
suspend fun proposeOrchardToIronwoodMigration(synchronizer: SdkSynchronizer): WritableMap {
val account =
synchronizer.getAccounts().firstOrNull()
?: throw IronwoodMigrationException("No account found for this wallet")

// Spends every Orchard note to the account's own internal receiver with
// the fee chosen so no Orchard change remains, leaving Sapling and
// transparent funds untouched. All-or-nothing: it throws rather than
// migrating part of the balance, since post-NU6.3 the turnstile forbids
// adding value back to Orchard and a remainder would be stranded.
val proposal = synchronizer.proposeOrchardToIronwoodMigration(account)
val feeZatoshi = proposal.totalFeeRequired().value

// The proposal exposes its fee but not its payment value, so the amount
// crossing is derived from what it consumes: the whole Orchard balance,
// minus that fee. Fail rather than quote a quantity we cannot source —
// this figure is displayed and then locked into the send scene.
val orchardAvailable =
synchronizer.walletBalances.value
?.get(account.accountUuid)
?.orchard
?.available
?.value
?: throw IronwoodMigrationException(
"Balances are not available yet; cannot quote the migration amount",
)

// The SDK built a fundable proposal, so a non-positive remainder means
// the balance we read disagrees with the notes the proposal selected -
// stale balances, or a differing notion of "available". Clamping that to
// zero would quote a zero-amount migration against a real fee, and the
// app locks this figure into the send scene. Fail loudly instead.
val amountZatoshi = orchardAvailable - feeZatoshi
if (amountZatoshi <= 0L) {
throw IronwoodMigrationException(
"Orchard balance ($orchardAvailable) does not cover the migration fee ($feeZatoshi)",
)
}

return Arguments.createMap().apply {
putString("amountZatoshi", amountZatoshi.toString())
putString("feeZatoshi", feeZatoshi.toString())
putString("proposalBase64", Base64.getEncoder().encodeToString(proposal.toByteArray()))
}
}
}
123 changes: 116 additions & 7 deletions android/src/main/java/app/edge/rnzcash/RNZcashModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -147,14 +147,16 @@ class RNZcashModule(
return@launch
}

// Parse in parallel, but fill the array on one thread:
// WritableArray is not safe for concurrent mutation, and
// these coroutines run on a multi-threaded dispatcher.
// Pushing in order also keeps the emitted order stable.
val parsedTxs =
transactionsToEmit
.map { tx -> async { parseTx(wallet, tx) } }
.map { it.await() }
val nativeArray = Arguments.createArray()
transactionsToEmit
.map { tx ->
launch {
val parsedTx = parseTx(wallet, tx)
nativeArray.pushMap(parsedTx)
}
}.forEach { it.join() }
parsedTxs.forEach { nativeArray.pushMap(it) }

sendEvent("TransactionEvent") { args ->
args.putString("alias", alias)
Expand All @@ -177,6 +179,10 @@ class RNZcashModule(
val orchardAvailableZatoshi = orchardBalances?.available ?: Zatoshi(0L)
val orchardTotalZatoshi = orchardBalances?.total ?: Zatoshi(0L)

val ironwoodBalances = accountBalance?.ironwood
val ironwoodAvailableZatoshi = ironwoodBalances?.available ?: Zatoshi(0L)
val ironwoodTotalZatoshi = ironwoodBalances?.total ?: Zatoshi(0L)

sendEvent("BalanceEvent") { args ->
args.putString("alias", alias)
args.putString("transparentAvailableZatoshi", transparentAvailableZatoshi.value.toString())
Expand All @@ -185,6 +191,8 @@ class RNZcashModule(
args.putString("saplingTotalZatoshi", saplingTotalZatoshi.value.toString())
args.putString("orchardAvailableZatoshi", orchardAvailableZatoshi.value.toString())
args.putString("orchardTotalZatoshi", orchardTotalZatoshi.value.toString())
args.putString("ironwoodAvailableZatoshi", ironwoodAvailableZatoshi.value.toString())
args.putString("ironwoodTotalZatoshi", ironwoodTotalZatoshi.value.toString())
}
}

Expand Down Expand Up @@ -542,6 +550,107 @@ class RNZcashModule(
}

//
// region Orchard -> Ironwood migration (NU6.3) — v1 surface
//
// Signatures mirror the iOS bridge exactly, because the JS API is the
// cross-platform contract. The SDK-backed work lives in IronwoodMigration.kt
// and src/ironwood — see those for why it is bound at runtime rather than
// called directly, and for which parts the Android SDK cannot serve yet.

/**
* Emits the wallet's current transaction set as a `TransactionEvent`.
*
* The `allTransactions` collector above delivers the full list on its first
* emission, but that fires while `initialize` is still settling — before
* JavaScript has attached its listeners — so the delivery is a race the app
* can lose. Afterwards the collector only re-emits transactions whose mined
* height or state changed, so anything that settled while nothing was
* listening would never reach the app again.
*
* JavaScript calls this from `subscribe()`, once its listeners are attached,
* which is the only point at which delivery is guaranteed. Re-sending known
* transactions is harmless: the app updates only the ones that changed.
*/
@ReactMethod
fun emitExistingTransactions(
alias: String,
promise: Promise,
) {
val wallet = getWallet(alias)
wallet.coroutineScope.launch {
try {
val txList = wallet.allTransactions.first()
// Parse in parallel, but fill the array on one thread: see the
// collector above - WritableArray is not safe for concurrent
// mutation and these run on a multi-threaded dispatcher.
val parsedTxs =
txList
.map { tx -> async { parseTx(wallet, tx) } }
.map { it.await() }
val nativeArray = Arguments.createArray()
parsedTxs.forEach { nativeArray.pushMap(it) }

sendEvent("TransactionEvent") { args ->
args.putString("alias", alias)
args.putArray("transactions", nativeArray)
}

// Record what we just sent, so the allTransactions collector does
// not treat these as unseen and emit the identical set a second
// time - which would parse every transaction twice on each login.
val emittedForAlias = emittedTransactions.getOrPut(alias) { mutableMapOf() }
txList.forEach { tx ->
emittedForAlias[tx.txId.txIdString()] =
EmittedTxState(
minedHeight = tx.minedHeight,
transactionState = tx.transactionState,
)
}
promise.resolve(null)
} catch (t: Throwable) {
promise.reject("Err", t)
}
}
Comment thread
peachbits marked this conversation as resolved.
}

@ReactMethod
fun ironwoodActivationHeight(
networkName: String,
promise: Promise,
) {
promise.wrap {
// An unrecognized network answers null, matching iOS and the
// `number | null` JS contract. Defaulting to mainnet (as the
// derivation methods in this file do) would report a height that is
// wrong for the caller's network rather than admitting it has none.
networks[networkName]?.let {
IronwoodMigration.ironwoodActivationHeight(it)?.toInt()
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

@ReactMethod
fun proposeOrchardToIronwoodMigration(
alias: String,
promise: Promise,
) {
// Synchronizer-bound work belongs on the wallet's own scope, like every
// other wallet method here: moduleScope outlives the synchronizer, so a
// proposal could still be running against one that `stop` has closed.
val wallet = getWallet(alias)
wallet.coroutineScope.launch {
try {
promise.resolve(
IronwoodMigration.proposeOrchardToIronwoodMigration(wallet),
)
} catch (t: Throwable) {
promise.reject("Err", t)
}
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

// endregion

// Utilities
//

Expand Down
15 changes: 15 additions & 0 deletions ios/RNZcash.m
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ @interface RCT_EXTERN_MODULE(RNZcash, RCTEventEmitter<RCTBridgeModule>)
rejecter:(RCTPromiseRejectBlock)reject
)

RCT_EXTERN_METHOD(emitExistingTransactions:(NSString *)alias
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject
)

// Derivation tool
RCT_EXTERN_METHOD(deriveViewingKey:(NSString *)seed
:(NSString *)network
Expand All @@ -83,6 +88,16 @@ @interface RCT_EXTERN_MODULE(RNZcash, RCTEventEmitter<RCTBridgeModule>)
rejecter:(RCTPromiseRejectBlock)reject
)

// Orchard -> Ironwood migration (NU6.3)
RCT_EXTERN_METHOD(proposeOrchardToIronwoodMigration:(NSString *)alias
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject
)
RCT_EXTERN_METHOD(ironwoodActivationHeight:(NSString *)networkName
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject
)

// Events
RCT_EXTERN_METHOD(supportedEvents)

Expand Down
Loading
Loading