diff --git a/CHANGELOG.md b/CHANGELOG.md index d274636a..fd8c5c78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/android/build.gradle b/android/build.gradle index 8b1875be..d8768bc0 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -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" } diff --git a/android/src/main/java/app/edge/rnzcash/IronwoodMigration.kt b/android/src/main/java/app/edge/rnzcash/IronwoodMigration.kt new file mode 100644 index 00000000..e60dda40 --- /dev/null +++ b/android/src/main/java/app/edge/rnzcash/IronwoodMigration.kt @@ -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())) + } + } +} diff --git a/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt b/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt index 3b9d4b81..28097927 100644 --- a/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt +++ b/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt @@ -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) @@ -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()) @@ -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()) } } @@ -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) + } + } + } + + @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() + } + } + } + + @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) + } + } + } + + // endregion + // Utilities // diff --git a/ios/RNZcash.m b/ios/RNZcash.m index 5bcc02b2..b8345ef3 100644 --- a/ios/RNZcash.m +++ b/ios/RNZcash.m @@ -65,6 +65,11 @@ @interface RCT_EXTERN_MODULE(RNZcash, RCTEventEmitter) 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 @@ -83,6 +88,16 @@ @interface RCT_EXTERN_MODULE(RNZcash, RCTEventEmitter) 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) diff --git a/ios/RNZcash.swift b/ios/RNZcash.swift index 7783053d..48e86dfc 100644 --- a/ios/RNZcash.swift +++ b/ios/RNZcash.swift @@ -400,6 +400,41 @@ class RNZcash: RCTEventEmitter { } } + /// Emits the wallet's current transaction set as a `TransactionEvent`. + /// + /// The synchronizer's event stream only carries transactions found in newly + /// scanned blocks (`foundTransactions`) or ones that just became mined + /// (`minedTransaction`), and `sendToJs` drops every event until JavaScript + /// attaches a listener. A transaction whose state settled while nothing was + /// listening — mined while the app was closed, or during a failed sync — is + /// therefore neither newly found nor newly mined on the next launch, and + /// would never reach the app: it would sit at height 0, "pending", forever. + /// + /// JavaScript calls this from `subscribe()`, after its listeners are + /// attached, which is the only point at which delivery is guaranteed. + /// Re-sending transactions the app already knows is harmless: it updates + /// only the ones whose height or amount actually changed. + @objc func emitExistingTransactions( + _ alias: String, resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + Task { + if let wallet = await synchronizerStore.get(alias) { + do { + let txs = try await wallet.synchronizer.allTransactions() + await wallet.sendTxs(transactions: txs) + resolve(nil) + } catch { + reject( + "emitExistingTransactionsError", "Failed to read transactions", error) + } + } else { + reject( + "emitExistingTransactionsError", "Wallet does not exist", genericError) + } + } + } + @objc func rescan( _ alias: String, resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock @@ -441,6 +476,113 @@ class RNZcash: RCTEventEmitter { } } + // MARK: Orchard -> Ironwood migration (NU6.3) + // + // The sweep is one ordinary proposal the app broadcasts through the normal + // createTransfer pipeline. The SDK 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, and is deliberately + // all-or-nothing: post-NU6.3 the turnstile forbids adding value back to + // Orchard, so a remainder would be stranded in a pool the wallet is leaving. + // + // Errors reject with the ZcashError message rather than a generic error. + + private func withMigrationAccount( + _ methodName: String, + _ alias: String, + _ resolve: @escaping RCTPromiseResolveBlock, + _ reject: @escaping RCTPromiseRejectBlock, + _ body: @escaping (WalletSynchronizer, AccountUUID) async throws -> Any? + ) { + Task { + guard let wallet = await synchronizerStore.get(alias) else { + reject(methodName, "Wallet does not exist", genericError) + return + } + guard let accountUUID = wallet.accountUUID else { + reject(methodName, "Account UUID not found", genericError) + return + } + do { + let result = try await body(wallet, accountUUID) + resolve(result) + } catch let error as ZcashError { + reject(methodName, error.message, error) + } catch { + reject(methodName, error.localizedDescription, error) + } + } + } + + /// Proposes the Orchard-only sweep to the account's own address, resolving + /// the JS `ImmediateMigrationProposal`. Execute the returned proposal through + /// the ordinary `createTransfer` path. + @objc func proposeOrchardToIronwoodMigration( + _ alias: String, resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + withMigrationAccount("proposeOrchardToIronwoodMigration", alias, resolve, reject) { + wallet, accountUUID in + let proposal = try await wallet.synchronizer.proposeOrchardToIronwoodMigration( + accountUUID: accountUUID) + let feeZatoshi = proposal.totalFeeRequired().amount + + // The proposal reports its fee but not its payment value, so the amount + // crossing is derived from what it consumes: the whole spendable Orchard + // balance, minus that fee. Fail rather than quote a figure we cannot + // source — it is displayed and then locked into the send scene. + let balances = try await wallet.synchronizer.getAccountsBalances() + guard let orchardAvailable = balances[accountUUID]?.orchardBalance.spendableValue.amount + else { + throw NSError( + domain: "proposeOrchardToIronwoodMigration", code: -1, + userInfo: [ + NSLocalizedDescriptionKey: + "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 "spendable". 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. + let amountZatoshi = orchardAvailable - feeZatoshi + guard amountZatoshi > 0 else { + throw NSError( + domain: "proposeOrchardToIronwoodMigration", code: -1, + userInfo: [ + NSLocalizedDescriptionKey: + "Orchard balance (\(orchardAvailable)) does not cover the migration fee (\(feeZatoshi))" + ]) + } + + return [ + "amountZatoshi": String(amountZatoshi), + "feeZatoshi": String(feeZatoshi), + "proposalBase64": try proposal.inner.serializedData().base64EncodedString(), + ] as NSDictionary + } + } + + /// The NU6.3 activation height for the named network, or null when it has + /// none. Served from consensus constants (ZIP 258) because no SDK exposes an + /// Ironwood accessor; stateless, so it needs no synchronizer. + @objc func ironwoodActivationHeight( + _ networkName: String, resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + switch networkName { + case "mainnet": + resolve(3_428_143) + case "testnet": + resolve(4_134_000) + default: + resolve(nil) + } + } + + // Derivation Tool private func getDerivationToolForNetwork(_ network: String) -> DerivationTool { switch network { @@ -693,6 +835,11 @@ class WalletSynchronizer: NSObject { let orchardAvailableZatoshi = orchardBalance.spendableValue let orchardTotalZatoshi = orchardBalance.total() + // Zero until the Ironwood (NU6.3) pool activates: + let ironwoodBalance = accountBalance.ironwoodBalance + let ironwoodAvailableZatoshi = ironwoodBalance.spendableValue + let ironwoodTotalZatoshi = ironwoodBalance.total() + return [ "alias": self.alias, "transparentAvailableZatoshi": String(transparentAvailableZatoshi.amount), @@ -701,6 +848,8 @@ class WalletSynchronizer: NSObject { "saplingTotalZatoshi": String(saplingTotalZatoshi.amount), "orchardAvailableZatoshi": String(orchardAvailableZatoshi.amount), "orchardTotalZatoshi": String(orchardTotalZatoshi.amount), + "ironwoodAvailableZatoshi": String(ironwoodAvailableZatoshi.amount), + "ironwoodTotalZatoshi": String(ironwoodTotalZatoshi.amount), ] as NSDictionary } @@ -758,17 +907,27 @@ class WalletSynchronizer: NSObject { return confTx } + /// Fire-and-forget: for the synchronizer's own event stream, where nothing is + /// waiting on the result. func emitTxs(transactions: [ZcashTransaction.Overview]) { Task { - var out: [NSDictionary] = [] - for tx in transactions { - let confTx = await parseTx(tx: tx) - out.append(confTx.nsDictionary) - } + await sendTxs(transactions: transactions) + } + } - let data: NSDictionary = ["alias": self.alias, "transactions": NSArray(array: out)] - emit("TransactionEvent", data) + /// The awaited form. `emitExistingTransactions` resolves its promise only + /// after this returns, so JavaScript's completion actually means the event + /// was sent - resolving off the detached Task above would report success + /// before any parsing had happened, and hide a failure inside it. + func sendTxs(transactions: [ZcashTransaction.Overview]) async { + var out: [NSDictionary] = [] + for tx in transactions { + let confTx = await parseTx(tx: tx) + out.append(confTx.nsDictionary) } + + let data: NSDictionary = ["alias": self.alias, "transactions": NSArray(array: out)] + emit("TransactionEvent", data) } } diff --git a/scripts/protos/zcash/service.proto b/scripts/protos/zcash/service.proto index b5033bc4..908af06a 100644 --- a/scripts/protos/zcash/service.proto +++ b/scripts/protos/zcash/service.proto @@ -116,6 +116,7 @@ message TreeState { uint32 time = 4; // Unix epoch time when the block was mined string saplingTree = 5; // sapling commitment tree state string orchardTree = 6; // orchard commitment tree state + string ironwoodTree = 7; // ironwood commitment tree state (NU6.3) } // Results are sorted by height, which makes it easy to issue another diff --git a/scripts/updateSources.ts b/scripts/updateSources.ts index d84968d5..9cc6299f 100644 --- a/scripts/updateSources.ts +++ b/scripts/updateSources.ts @@ -27,28 +27,37 @@ async function main(): Promise { buildVendoredDeps() } -// The Swift SDK version to vendor. The matching libzcashlc.xcframework is -// downloaded from this release's assets (see rebuildXcframework). -const ZCASH_SWIFT_SDK_VERSION = '2.6.0-alpha.6' +// The Swift SDK commit to vendor: the 2.7.0-rc.4 tag, the release the Zcash team +// confirmed production-ready for Ironwood (NU6.3). Pinned by commit rather than +// tag name so the checkout is immutable even if the tag is ever moved. +const ZCASH_SWIFT_SDK_COMMIT = 'fb9f6cf46fa725efa6cb9e646e13a94f05a293bf' -// SHA-256 of the libzcashlc.xcframework.zip release asset for the version -// above. The download is verified against this pin before it is unpacked, so a -// tampered or swapped upstream asset fails the build instead of injecting -// attacker-controlled native code. Update this whenever the SDK version bumps: -// curl -fL https://github.com/zcash/zcash-swift-wallet-sdk/releases/download//libzcashlc.xcframework.zip | shasum -a 256 -// (matches the `checksum:` in the SDK tag's own Package.swift binaryTarget) +// SHA-256 of the libzcashlc.xcframework.zip this package links. +// +// The asset is not addressed by a URL written here: which zip to fetch, and its +// checksum, come from the pinned checkout's own binaryTarget (readBinaryTarget +// below), so the FFI can never drift from the SDK source we vendor. This +// constant is the reviewed copy of that checksum - the build stops if the two +// disagree, so bumping ZCASH_SWIFT_SDK_COMMIT has to change the native code +// deliberately rather than silently. The download is checked against it too, +// before anything is unpacked, so a tampered or swapped release asset fails the +// build instead of injecting attacker-controlled native code. +// +// To refresh it, take the `checksum:` from the new commit's Package.swift, or: +// curl -fL | shasum -a 256 const LIBZCASHLC_XCFRAMEWORK_SHA256 = - 'c58c1714440f8fd40bed33eefa3be325896e58eb568ed8747c05a27dae3a74b2' + 'c012c2b682191f027c1874ecde84adeeaef26dbb3e827dd5f29deb0eb8af0ef2' function downloadSources(): void { getRepo( 'ZcashLightClientKit', 'https://github.com/zcash/zcash-swift-wallet-sdk.git', - // 2.6.0-alpha.6: - '4303068e9282bb8b03bd94807b7d8ad268de75bf' + ZCASH_SWIFT_SDK_COMMIT ) // libzcashlc is no longer a separate package as of SDK 2.5.x — it ships as a - // binaryTarget zip on the SDK's GitHub release, downloaded in rebuildXcframework(). + // release-asset zip named by the checkout's own binaryTarget, downloaded in + // rebuildXcframework(). Both read that one declaration, so SwiftPM builds of + // the checkout (the vendored-deps wrapper) link the binary this package ships. } /** @@ -64,10 +73,19 @@ function downloadSources(): void { * We fix this by simply re-building the XCFramework. */ async function rebuildXcframework(): Promise { + // Take the asset to download from the pinned checkout itself, so the FFI is + // always the one this SDK source was released against: + const { url: zipUrl, checksum } = await readBinaryTarget() + if (checksum !== LIBZCASHLC_XCFRAMEWORK_SHA256) { + throw new Error( + `The pinned SDK checkout links libzcashlc ${checksum}, but this package pins ${LIBZCASHLC_XCFRAMEWORK_SHA256}. ` + + `Bumping ZCASH_SWIFT_SDK_COMMIT changes the native FFI too - review the new release (${zipUrl}) ` + + `and update LIBZCASHLC_XCFRAMEWORK_SHA256 to match.` + ) + } + // Download the prebuilt libzcashlc XCFramework from the SDK's GitHub release. - // (The SDK's Package.swift `.binaryTarget` points at this same asset.) console.log('Downloading libzcashlc XCFramework...') - const zipUrl = `https://github.com/zcash/zcash-swift-wallet-sdk/releases/download/${ZCASH_SWIFT_SDK_VERSION}/libzcashlc.xcframework.zip` const zipPath = join(tmp, 'libzcashlc.xcframework.zip') loudExec(tmp, ['curl', '--fail', '--location', '--output', zipPath, zipUrl]) @@ -115,6 +133,32 @@ async function rebuildXcframework(): Promise { ]) } +interface BinaryTarget { + url: string + checksum: string +} + +/** + * Reads the libzcashlc binaryTarget out of the pinned checkout's Package.swift, + * where upstream declares the FFI build that matches this SDK source. Reading it + * instead of repeating the release tag here is what makes a source/binary + * mismatch unrepresentable: there is only one place the pair is written down. + */ +async function readBinaryTarget(): Promise { + const path = 'tmp/ZcashLightClientKit/Package.swift' + const text = await disklet.getText(path) + // The file declares exactly one binaryTarget, as a url/checksum pair. It sits + // in the else branch of upstream's local-FFI switch, so it is in the text + // whether or not a LocalPackages checkout happens to be active: + const match = text.match( + /\.binaryTarget\([^)]*?url:\s*"([^"]+)"[^)]*?checksum:\s*"([0-9a-f]{64})"/ + ) + if (match == null) { + throw new Error(`Cannot find the libzcashlc binaryTarget in ${path}`) + } + return { url: match[1], checksum: match[2] } +} + /** * Copies swift code, with modifications. */ @@ -173,6 +217,13 @@ async function copySwift(): Promise { 'Bundle.module.bundleURL.appendingPathComponent("checkpoints/testnet/")', 'Bundle.main.url(forResource: "zcash-testnet", withExtension: "bundle")!' ) + // The regtest checkpoint directory does not exist as a resource (and + // RegtestCheckpointSource never reads it — it synthesizes an empty-tree + // checkpoint), so this only needs to compile under CocoaPods: + .replace( + 'Bundle.module.bundleURL.appendingPathComponent("checkpoints/regtest/")', + 'Bundle.main.bundleURL.appendingPathComponent("checkpoints/regtest/")' + ) // This block of code uses "Bundle.module" too, // but we can just delete it since phone builds don't need it: .replace(/static let macOS = BundleCheckpointURLProvider.*}\)/s, '') diff --git a/src/react-native.ts b/src/react-native.ts index 3d71f674..9d7da1a2 100644 --- a/src/react-native.ts +++ b/src/react-native.ts @@ -8,6 +8,7 @@ import { import { Addresses, CreateTransferOpts, + ImmediateMigrationProposal, InitializerConfig, Network, ProposalSuccess, @@ -40,6 +41,19 @@ export const Tools = { ): Promise => { const result = await RNZcash.isValidAddress(address, network) return result + }, + /** + * The NU6.3 (Ironwood) activation height for the network, or null when the + * network has none. Stateless — safe to call before any synchronizer + * exists; the app gates migration UI on the chain reaching this height. + * Answers on both platforms: these are consensus constants (ZIP 258), which + * neither SDK exposes. + */ + getIronwoodActivationHeight: async ( + network: Network = 'mainnet' + ): Promise => { + const result = await RNZcash.ironwoodActivationHeight(network) + return result } } @@ -88,6 +102,17 @@ export class Synchronizer { await RNZcash.rescan(this.alias) } + /** + * Proposes the Orchard-only sweep to the wallet's own address. Execute the + * returned proposal through the ordinary createTransfer path. + */ + async proposeOrchardToIronwoodMigration(): Promise< + ImmediateMigrationProposal + > { + const result = await RNZcash.proposeOrchardToIronwoodMigration(this.alias) + return result + } + async proposeTransfer(opts: ProposeTransferOpts): Promise { const result = await RNZcash.proposeTransfer( this.alias, @@ -139,22 +164,37 @@ export class Synchronizer { onError }: SynchronizerCallbacks): void { this.setListener('BalanceEvent', event => { + // Both platforms emit these, but an older native build paired with a + // newer JS bundle would not; default them so the shape is consistent: + event.ironwoodAvailableZatoshi = event.ironwoodAvailableZatoshi ?? '0' + event.ironwoodTotalZatoshi = event.ironwoodTotalZatoshi ?? '0' + const { transparentAvailableZatoshi, transparentTotalZatoshi, saplingAvailableZatoshi, saplingTotalZatoshi, orchardAvailableZatoshi, - orchardTotalZatoshi + orchardTotalZatoshi, + ironwoodAvailableZatoshi, + ironwoodTotalZatoshi } = event + // The deprecated sums mean "the whole wallet": ironwood must be + // included so funds don't vanish from them mid-migration. event.availableZatoshi = add( - add(transparentAvailableZatoshi, saplingAvailableZatoshi), - orchardAvailableZatoshi + add( + add(transparentAvailableZatoshi, saplingAvailableZatoshi), + orchardAvailableZatoshi + ), + ironwoodAvailableZatoshi ) event.totalZatoshi = add( - add(transparentTotalZatoshi, saplingTotalZatoshi), - orchardTotalZatoshi + add( + add(transparentTotalZatoshi, saplingTotalZatoshi), + orchardTotalZatoshi + ), + ironwoodTotalZatoshi ) onBalanceChanged(event) }) @@ -162,6 +202,20 @@ export class Synchronizer { this.setListener('TransactionEvent', onTransactionsChanged) this.setListener('UpdateEvent', onUpdate) this.setListener('ErrorEvent', onError) + + // Native drops events until a listener exists, and its transaction stream + // only carries what is newly found or newly mined. A transaction that + // settled while nothing was listening - mined while the app was closed, or + // during a failed sync - would otherwise never be reported again and would + // stay pending forever. Ask for the current set now that the listeners + // above are attached; this ordering is what makes the delivery reliable. + RNZcash.emitExistingTransactions(this.alias).catch((error: unknown) => { + onError({ + alias: this.alias, + level: 'error', + message: `emitExistingTransactions failed: ${String(error)}` + }) + }) } private setListener( diff --git a/src/types.ts b/src/types.ts index a33a50f2..c3fa0a46 100644 --- a/src/types.ts +++ b/src/types.ts @@ -50,8 +50,11 @@ export interface BalanceEvent { saplingTotalZatoshi: string orchardAvailableZatoshi: string orchardTotalZatoshi: string + /** Zero until the Ironwood (NU6.3) pool activates. */ + ironwoodAvailableZatoshi: string + ironwoodTotalZatoshi: string - /** @deprecated */ + /** @deprecated Sum of every pool, including ironwood */ availableZatoshi: string totalZatoshi: string } @@ -115,3 +118,30 @@ export interface Addresses { saplingAddress: string transparentAddress: string } + +// +// Orchard -> Ironwood migration (NU6.3). +// +// The sweep is one ordinary proposal the app broadcasts through the normal +// createTransfer pipeline, so there is no migration lifecycle to model here: +// the app decides whether to offer it from the Orchard balance and the +// activation height, and a broadcast sweep empties that balance. +// + +/** + * The Orchard-only sweep: spends every Orchard note to the wallet's own + * address, with the fee chosen so no Orchard change remains. Sapling and + * transparent funds are untouched, and it is all-or-nothing — post-NU6.3 the + * turnstile forbids adding value back to Orchard, so a remainder would be + * stranded. + */ +export interface ImmediateMigrationProposal { + /** + * Net amount crossing into Ironwood: the spendable Orchard balance minus + * `feeZatoshi`. It deliberately excludes the wallet's other pools. + */ + amountZatoshi: string + feeZatoshi: string + /** Opaque ordinary-transfer proposal; execute it via createTransfer. */ + proposalBase64: string +}