diff --git a/.gitignore b/.gitignore index a74c692c..8e4f4d2c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /android/build/ /ios/libzcashlc.xcframework/ /ios/ZCashLightClientKit/ +/ios/vendored/ /ios/zcashlc.h /lib/ /tmp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b648c58d..e9f9df97 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.13.0 (2026-07-31) ## 0.12.2 (2026-07-14) 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/assets/co.electriccoin.zcash/checkpoint/mainnet/3430000.json b/android/src/main/assets/co.electriccoin.zcash/checkpoint/mainnet/3430000.json index 5d0d8a56..1d812a83 100644 --- a/android/src/main/assets/co.electriccoin.zcash/checkpoint/mainnet/3430000.json +++ b/android/src/main/assets/co.electriccoin.zcash/checkpoint/mainnet/3430000.json @@ -4,5 +4,6 @@ "hash": "00000000004d67f4b3b92bc32d6cd9757ba54fb30cf6aa165c21c7e3d28057e4", "time": 1785388067, "saplingTree": "011db0ee2ff4cc17fcde39e5d443d7b578243cbf0d47e8c580d9c6b23dce822518001f00014dea9929f27098f278619ea2435dfac6bc77c3c072d2536eeb0d6b97b74b5d110164c56d99f514da8ec180d2fa7588527333dc4a51c4e7fead047cbd4bcc206f06010db222daf0263ecbb8a1b744026f594fc5a7f10c78ddb4e8daff1d2d48de0644000001b4e6c8481436ef39769918168ee1799a1865e6026c36bb6b3a69f03e2373803101cd3b7ec1ddf93ccf54df8ddb3b816a1713f0f0a12b1c85fd01dde000c23a691d0175dd6282819423b6a12ace8d6e29c291818f0e26c80caf5d63573afd4d3e9a3e019c20e2f71d9a07197eddd9ad5d3904a73596be6663a96d2dc8570a3a28f660220001ae95ee39732985f266c923beba45a238ed04c40def0ed8a58457197cb4d6d9310149376d0c5dbf38c049546f37f27cf3e2940f756e8c3aaa8373d5374a3a1ef96500000000000190eb9e2bc82b8b980aaa63ba44db65328553ba840c38c5011a465efd8b233b2200013e2598f743726006b8de42476ed56a55a75629a7b82e430c4e7c101a69e9b02a011619f99023a69bb647eab2d2aa1a73c3673c74bb033c3c4930eacda19e6fd93b0000000160272b134ca494b602137d89e528c751c06d3ef4a87a45f33af343c15060cc1e0000000000", - "orchardTree": "01ebde304f932abcaa9f2f5631dd202b86fabca18dcb0c624e29ba87f2329dd113001f000189a2dadd09ae6a0eb4cf875ebbe30ca13de0950f468a555d19eb408a838ba32f00016f394f7ec9ed2af1e32dce28185d34fc3deb606522cba009c42bb29423f61e2f0197e4255b0f109c1fd368052a4d6687b2411d593301387f777fbbba86f6128623000000017a1e310afabf1c4d8540c3b023d3039bdc28ad0127c1e4a97383ad5a53ab0c1b00000000000001706f695281168aeb90709b841ba9ddaf3a898ec0033019f259dafe76dcff303801ca84c7503ef61999fc84c803716bc3f5d2bb67710c8136e424f5f2e97c96973b0150fc4bd01275d506ffc3b8391dc5dc9cf837cacfb2a3412d7907cda594d8633b012829e8aacdf1501baaeb5cb6e189d4e7182228e3d4b9acf54713595241e97f21017c8ece2b2ab2355d809b58809b21c7a5e95cfc693cd689387f7533ec8749261e01cc2dcaa338b312112db04b435a706d63244dd435238f0aa1e9e1598d35470810012dcc4273c8a0ed2337ecf7879380a07e7d427c7f9d82e538002bd1442978402c01daf63debf5b40df902dae98dadc029f281474d190cddecef1b10653248a234150001e2bca6a8d987d668defba89dc082196a922634ed88e065c669e526bb8815ee1b000000000000" + "orchardTree": "01ebde304f932abcaa9f2f5631dd202b86fabca18dcb0c624e29ba87f2329dd113001f000189a2dadd09ae6a0eb4cf875ebbe30ca13de0950f468a555d19eb408a838ba32f00016f394f7ec9ed2af1e32dce28185d34fc3deb606522cba009c42bb29423f61e2f0197e4255b0f109c1fd368052a4d6687b2411d593301387f777fbbba86f6128623000000017a1e310afabf1c4d8540c3b023d3039bdc28ad0127c1e4a97383ad5a53ab0c1b00000000000001706f695281168aeb90709b841ba9ddaf3a898ec0033019f259dafe76dcff303801ca84c7503ef61999fc84c803716bc3f5d2bb67710c8136e424f5f2e97c96973b0150fc4bd01275d506ffc3b8391dc5dc9cf837cacfb2a3412d7907cda594d8633b012829e8aacdf1501baaeb5cb6e189d4e7182228e3d4b9acf54713595241e97f21017c8ece2b2ab2355d809b58809b21c7a5e95cfc693cd689387f7533ec8749261e01cc2dcaa338b312112db04b435a706d63244dd435238f0aa1e9e1598d35470810012dcc4273c8a0ed2337ecf7879380a07e7d427c7f9d82e538002bd1442978402c01daf63debf5b40df902dae98dadc029f281474d190cddecef1b10653248a234150001e2bca6a8d987d668defba89dc082196a922634ed88e065c669e526bb8815ee1b000000000000", + "ironwoodTree": "0195a9e7d999e14207ff4e9883155dde6db90151992c343160d954974d617ef73e01257902e6eb98cecb851e5b2c1626b6a83163635690f6ad58767db0a61c18c91c1f0105046a370243c0b7a683c5ca12b758483e0a60389e1c73f7ca241df4432cb93100018e94e36a63d81c45bc3a7fcfd2a01c86437cee5a9f023c44dda352345e5c210f0000000000000133ba0b8f833537335255d5d80b7e7681b186ecb403934395fe169b070a40fb3c014739080215ed1ebbe3e6140d2646dbd37e03b3478805c393dea76922766b9820013a37348f374c1bbe8e6ef30d2a1e037c193a59a4cfd87dbaa24743c4248e8c2800000000000000000000000000000000000000" } 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/react-native-zcash.podspec b/react-native-zcash.podspec index 1084086f..caed2864 100644 --- a/react-native-zcash.podspec +++ b/react-native-zcash.podspec @@ -2,6 +2,40 @@ require "json" package = JSON.parse(File.read(File.join(__dir__, "package.json"))) +# Each bundled C dep module (ios/vendored/cmodules// — headers + +# module.modulemap) becomes one relative clang include path, so the in-pod +# ZcashLightClientKit source can resolve the C modules that the pre-built Swift +# dependency modules (SwiftNIO / GRPC) import. +cmodule_flags = Dir.glob(File.join(__dir__, "ios/vendored/cmodules/*")) + .select { |p| File.directory?(p) } + .map { |p| "-Xcc -I\"$(PODS_TARGET_SRCROOT)/ios/vendored/cmodules/#{File.basename(p)}\"" } + .join(" ") + +# The pre-built Swift modules under ios/vendored/ are only readable by the +# EXACT Swift compiler that produced them (the binary .swiftmodule format is +# not stable across compilers, and the stable alternative — library-evolution +# .swiftinterface — is unavailable because swift-nio rejects evolution builds +# by upstream policy; see apple/swift-nio#2470/#2897, closed "not planned"). +# Fail fast with instructions instead of letting the build die later on a +# cryptic "module compiled with Swift X cannot be imported by Swift Y" error. +stamp_path = File.join(__dir__, "ios/vendored/swift-version.txt") +if File.exist?(stamp_path) + built_with = File.read(stamp_path).strip + local_swift = `xcrun swift --version 2>/dev/null`[/swiftlang-[0-9.]+/] + if !built_with.empty? && !local_swift.nil? && built_with != local_swift + raise <<~MSG + react-native-zcash: the prebuilt Swift dependency modules in ios/vendored/ + were built with #{built_with}, but this machine's Swift compiler is + #{local_swift}. Binary .swiftmodule files only load under the exact + compiler that produced them. + + Fix: rebuild the vendored dependencies with your toolchain: + cd node_modules/react-native-zcash && npm run update-sources + (or switch to the Xcode whose Swift is #{built_with}) + MSG + end +end + Pod::Spec.new do |s| s.name = package['name'] s.version = package['version'] @@ -15,6 +49,10 @@ Pod::Spec.new do |s| :git => "https://github.com/EdgeApp/react-native-zcash.git", :tag => "v#{s.version}" } + + # The bridge + the vendored ZcashLightClientKit Swift source, compiled in-pod + # as ONE module (so the bridge uses SDK types directly — see copySwift in + # scripts/updateSources.ts). s.source_files = "ios/react-native-zcash-Bridging-Header.h", "ios/RNZcash.m", @@ -25,10 +63,40 @@ Pod::Spec.new do |s| "zcash-mainnet" => "ios/ZCashLightClientKit/Resources/checkpoints/mainnet/*.json", "zcash-testnet" => "ios/ZCashLightClientKit/Resources/checkpoints/testnet/*.json" } - s.vendored_frameworks = "ios/libzcashlc.xcframework" s.dependency "MnemonicSwift", "~> 2.2" - s.dependency "gRPC-Swift", "~> 1.8" - s.dependency "SQLite.swift/standalone", "~> 0.14" s.dependency "React-Core" + + # The Rust core (a binaryTarget on the SDK's GitHub release) plus the + # pre-built SwiftPM deps (see below). Vendored frameworks are real link + # inputs: CocoaPods adds them to the app link, where the linker pulls + # members on demand to satisfy the in-pod SDK source's references. + s.vendored_frameworks = + "ios/libzcashlc.xcframework", + "ios/vendored/libZcashDeps.xcframework" + + # --------------------------------------------------------------------------- + # The SDK's SwiftPM-only dependencies (grpc-swift, SwiftNIO, SwiftProtobuf, + # SQLite.swift) pre-built into one static lib per platform, plus their Swift + # and C modules. grpc-swift 1.24+ ships SwiftPM-only with no podspec, so these + # can no longer be CocoaPods `dependency`s; vendoring them as a static binary + # keeps the host app on STATIC frameworks (consuming the SDK via + # spm_dependency would force the whole app onto dynamic frameworks). + # + # sqlite3 is not in this binary: on Apple platforms SQLite.swift has no C-shim + # target — it imports the system `sqlite3` clang module — so its sqlite3_* + # references resolve at app link time from whatever the host links (in Edge, + # its own sqlite pod and/or the sqlite embedded in libzcashlc). Note: Edge's + # pre-existing duplicate-sqlite3 ld warnings come from libzcashlc vs + # react-native-piratechain's libpiratelc (both Rust cores embed sqlite3) and + # are unrelated to this package's deps binary. + # + # Regenerate ios/vendored/ with `npm run update-sources` + # (scripts/buildVendoredDeps.ts). + # --------------------------------------------------------------------------- + s.preserve_paths = "ios/vendored/**/*" + s.pod_target_xcconfig = { + "SWIFT_INCLUDE_PATHS" => "\"$(PODS_TARGET_SRCROOT)/ios/vendored/modules\"", + "OTHER_SWIFT_FLAGS" => cmodule_flags + } end diff --git a/scripts/buildVendoredDeps.ts b/scripts/buildVendoredDeps.ts new file mode 100644 index 00000000..11995da2 --- /dev/null +++ b/scripts/buildVendoredDeps.ts @@ -0,0 +1,348 @@ +// Builds the SwiftPM dependency graph that the vendored ZcashLightClientKit +// source links against — grpc-swift, SwiftNIO, SwiftProtobuf, SQLite.swift and +// their C shims — into ONE static library per platform, plus the Swift +// `.swiftmodule`s and C `module.modulemap`s the in-pod SDK source needs to +// `import` them. +// +// Why this exists: as of the modern SDK, grpc-swift (1.24+) ships SwiftPM-only +// with no podspec, so it can no longer be a CocoaPods `dependency`. Instead of +// forcing the whole host app onto dynamic frameworks (the only way to consume +// the SDK via `spm_dependency`), we pre-build just these leaf dependencies into +// a static binary. The host app stays on static frameworks; the SDK *source* +// keeps compiling in-pod exactly as before (see copySwift in updateSources.ts). +// +// Output (all under ios/vendored/, gitignored, shipped in the npm tarball): +// libZcashDeps.xcframework - merged static lib (device arm64; sim arm64+x86_64) +// modules/.swiftmodule - Swift dep modules (all arch slices) +// cmodules// - C dep modules (headers + module.modulemap) +// swift-version.txt - the Swift compiler that produced the modules +// +// The per-platform archives are packaged as ONE xcframework because +// vendored_frameworks is a real link input: CocoaPods places it on the app +// link line, where the linker pulls members on demand. (pod_target_xcconfig +// OTHER_LDFLAGS cannot do this job: a static-framework pod's Libtool step +// ignores it, and pod-target settings never propagate to the app link.) +// +// Compiler coupling: the binary .swiftmodule format is only readable by the +// exact Swift compiler that wrote it. The stable alternative (library-evolution +// .swiftinterface) is off the table BY UPSTREAM POLICY, not by accident: +// swift-nio's @inlinable-heavy style is incompatible with evolution mode, and +// the maintainers have closed every request as "not planned" (apple/swift-nio +// #2467, #2470, #2897). Don't burn time re-testing evolution on toolchain +// bumps; this only changes if NIO reverses that policy or leaves the SDK's +// dependency graph. swift-version.txt records the producing compiler so the +// podspec can fail fast with instructions when the consuming Xcode doesn't match. + +import { execFileSync } from 'child_process' +import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'fs' +import { join } from 'path' + +// This repo's @types/node predates fs.cpSync/rmSync, and the sibling scripts +// already shell out for file ops, so do the same here. +function rm(path: string): void { + execFileSync('rm', ['-rf', path]) +} +function cp(src: string, dest: string): void { + execFileSync('cp', ['-R', src, dest]) +} + +const root = join(__dirname, '..') +const tmp = join(root, 'tmp') +const sdkClone = join(tmp, 'ZCashLightClientKit') +const wrapper = join(tmp, 'deps-wrapper') +const vendored = join(root, 'ios/vendored') + +// Targets that must NOT go into the deps binary: +// - ZcashLightClientKit: compiled in-pod from source, not vendored as a binary +// - ZcashDepsWrapper: our throwaway entry-point target +// (sqlite3 needs no exclusion: on Apple platforms SQLite.swift has no C-shim +// target — it imports the system `sqlite3` clang module, so its sqlite3_* +// references resolve at app link time from whatever the host app links.) +const EXCLUDED_TARGETS = ['ZcashLightClientKit', 'ZcashDepsWrapper'] + +interface Platform { + archs: string[] + destination: string + dir: string +} + +// Arch baseline matches the libzcashlc.xcframework the SDK ships: arm64 +// devices, arm64 + x86_64 simulators (so Intel Macs can still build). +const PLATFORMS: Platform[] = [ + { + archs: ['arm64'], + destination: 'generic/platform=iOS', + dir: 'ios-arm64' + }, + { + archs: ['arm64', 'x86_64'], + destination: 'generic/platform=iOS Simulator', + dir: 'ios-arm64-simulator' + } +] + +export function buildVendoredDeps(): void { + console.log('Building vendored SwiftPM dependency binary...') + rm(vendored) + mkdirSync(join(vendored, 'modules'), { recursive: true }) + mkdirSync(join(vendored, 'cmodules'), { recursive: true }) + + writeWrapperPackage() + + for (const platform of PLATFORMS) { + console.log(` Compiling deps for ${platform.dir}...`) + const dd = join(tmp, `deps-dd-${platform.dir}`) + // Always start from clean DerivedData: reusing it across SDK version bumps + // poisons the build with stale precompiled modules of the libzcashlc + // binary-target header ("zcashlc.h has been modified since the module file + // was built"), surfacing as bogus cannot-find-FFI-symbol errors. + rm(dd) + loud(wrapper, [ + 'xcodebuild', + '-scheme', + 'ZcashDepsWrapper', + '-configuration', + 'Release', + '-destination', + platform.destination, + '-derivedDataPath', + dd, + // Honor the Package.resolved copied from the SDK checkout; hard-fail on + // any version drift instead of silently re-resolving: + '-disableAutomaticPackageResolution', + `ARCHS=${platform.archs.join(' ')}`, + 'ONLY_ACTIVE_ARCH=NO', + 'BUILD_LIBRARY_FOR_DISTRIBUTION=NO', + 'SKIP_INSTALL=NO', + 'build' + ]) + + mergeDeps(dd, platform) + harvestModules(dd) + } + + // Package the per-platform archives as ONE xcframework: vendored_frameworks + // is a real link input (CocoaPods puts it on the app link line, unlike + // pod_target_xcconfig OTHER_LDFLAGS, which a static-framework pod's Libtool + // step silently ignores). + console.log(' Creating libZcashDeps.xcframework...') + const xcframework = join(vendored, 'libZcashDeps.xcframework') + rm(xcframework) + const libArgs: string[] = [] + for (const platform of PLATFORMS) { + // CocoaPods requires a UNIFORM library basename across xcframework slices + // (mirroring libzcashlc.xcframework); stage each platform's lib under the + // same name in its own directory: + const stage = join(tmp, `xcfw-${platform.dir}`) + rm(stage) + mkdirSync(stage, { recursive: true }) + cp( + join(vendored, `libZcashDeps-${platform.dir}.a`), + join(stage, 'libZcashDeps.a') + ) + libArgs.push('-library', join(stage, 'libZcashDeps.a')) + } + loud(tmp, [ + 'xcodebuild', + '-create-xcframework', + ...libArgs, + '-output', + xcframework + ]) + for (const platform of PLATFORMS) { + rm(join(vendored, `libZcashDeps-${platform.dir}.a`)) + } + + writeCompilerStamp() + assertHarvestComplete() + console.log('Vendored deps built.') +} + +// A throwaway SwiftPM package that depends on the SDK so SwiftPM builds the +// SDK's dependency graph; we then harvest those compiled deps. The SDK +// checkout's Package.resolved is copied in so the graph resolves to the EXACT +// versions the SDK release pinned, not just whatever satisfies its ranges. +function writeWrapperPackage(): void { + rm(wrapper) + mkdirSync(join(wrapper, 'Sources/ZcashDepsWrapper'), { recursive: true }) + writeFileSync( + join(wrapper, 'Package.swift'), + `// swift-tools-version:5.9 +import PackageDescription +let package = Package( + name: "ZcashDepsWrapper", + // Match the SDK's own floor (iOS 13) so the prebuilt deps import cleanly + // from any host at or above it (Edge develop pins 15.6): + platforms: [.iOS(.v13)], + products: [.library(name: "ZcashDepsWrapper", type: .static, targets: ["ZcashDepsWrapper"])], + dependencies: [.package(path: ${JSON.stringify(sdkClone)})], + targets: [.target(name: "ZcashDepsWrapper", dependencies: [ + .product(name: "ZcashLightClientKit", package: "ZCashLightClientKit") + ])] +) +` + ) + writeFileSync( + join(wrapper, 'Sources/ZcashDepsWrapper/Empty.swift'), + '@_exported import ZcashLightClientKit\n' + ) + const sdkPins = join(sdkClone, 'Package.resolved') + if (!existsSync(sdkPins)) { + throw new Error(`SDK checkout has no Package.resolved at ${sdkPins}`) + } + cp(sdkPins, join(wrapper, 'Package.resolved')) +} + +// Merge every dependency target's compiled objects into one static lib per +// arch, then lipo the arches into the platform lib. Merging raw per-target +// objects (not the prelinked master objects) keeps every public symbol. +function mergeDeps(dd: string, platform: Platform): void { + const objectsRoot = join(dd, 'Build/Intermediates.noindex') + const archLibs: string[] = [] + + for (const arch of platform.archs) { + const objects = findObjects(objectsRoot, arch).filter(path => { + if (/IntegrationTests|Benchmarks|Tests\.build|Example/.test(path)) { + return false + } + return !EXCLUDED_TARGETS.some(target => + path.includes(`/${target}.build/`) + ) + }) + if (objects.length === 0) { + throw new Error( + `No ${arch} dependency objects found under ${objectsRoot}` + ) + } + const listFile = join(tmp, `deps-objects-${platform.dir}-${arch}.txt`) + writeFileSync(listFile, objects.join('\n')) + const archLib = join(tmp, `libZcashDeps-${platform.dir}-${arch}.a`) + rm(archLib) + loud(tmp, ['libtool', '-static', '-o', archLib, '-filelist', listFile]) + archLibs.push(archLib) + } + + const out = join(vendored, `libZcashDeps-${platform.dir}.a`) + rm(out) + if (archLibs.length === 1) { + cp(archLibs[0], out) + } else { + loud(tmp, ['lipo', '-create', ...archLibs, '-output', out]) + } +} + +function findObjects(base: string, arch: string): string[] { + const out: string[] = [] + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name) + if (entry.isDirectory()) walk(full) + else if ( + entry.name.endsWith('.o') && + dir.endsWith(`Objects-normal/${arch}`) + ) { + out.push(full) + } + } + } + walk(base) + return out +} + +// Copy the dep Swift `.swiftmodule`s (unioning arch slices across the +// per-platform builds) and the C modules (headers + module.modulemap) the SDK +// source imports. +function harvestModules(dd: string): void { + const products = findProductsDir(dd) + for (const entry of readdirSync(products)) { + if (!entry.endsWith('.swiftmodule')) continue + const name = entry.replace('.swiftmodule', '') + if (EXCLUDED_TARGETS.includes(name)) continue + const src = join(products, entry) + const dest = join(vendored, 'modules', entry) + if (statSync(src).isDirectory()) { + // Union the per-platform arch slices into one .swiftmodule bundle. + mkdirSync(dest, { recursive: true }) + cp(`${src}/.`, dest) + } else if (!existsSync(dest)) { + cp(src, dest) + } + } + // C modules: each compiled C target maps to a checkout include/ dir with a + // module.modulemap (synthesize a simple umbrella one if SwiftPM generated it). + const checkouts = join(dd, 'SourcePackages/checkouts') + if (!existsSync(checkouts)) return + for (const obj of readdirSync(products)) { + if (!obj.endsWith('.o')) continue + const mod = obj.replace('.o', '') + if (existsSync(join(vendored, 'cmodules', mod))) continue + const inc = findInclude(checkouts, mod) + if (inc == null) continue + const dest = join(vendored, 'cmodules', mod) + cp(inc, dest) + if (!existsSync(join(dest, 'module.modulemap'))) { + writeFileSync( + join(dest, 'module.modulemap'), + `module ${mod} {\n umbrella "."\n export *\n}\n` + ) + } + } +} + +function findProductsDir(dd: string): string { + const base = join(dd, 'Build/Products') + const entry = readdirSync(base).find(name => name.startsWith('Release-')) + if (entry == null) throw new Error(`No Products dir under ${base}`) + return join(base, entry) +} + +function findInclude(checkouts: string, mod: string): string | undefined { + for (const pkg of readdirSync(checkouts)) { + const inc = join(checkouts, pkg, 'Sources', mod, 'include') + if (existsSync(inc) && readdirSync(inc).some(f => f.endsWith('.h'))) { + return inc + } + } + return undefined +} + +// Record which Swift compiler produced the .swiftmodules (see the compiler +// coupling note in the file header). The podspec checks this at install time. +function writeCompilerStamp(): void { + const versionOutput = execFileSync('xcrun', ['swift', '--version'], { + encoding: 'utf8' + }) + const match = versionOutput.match(/swiftlang-[0-9.]+/) + if (match == null) { + throw new Error( + `Cannot parse Swift compiler version from: ${versionOutput}` + ) + } + writeFileSync(join(vendored, 'swift-version.txt'), `${match[0]}\n`) +} + +// Guard against a layout change in xcodebuild/SwiftPM silently producing an +// empty harvest (the build would only fail much later, in a consuming app). +function assertHarvestComplete(): void { + const moduleCount = readdirSync(join(vendored, 'modules')).filter(name => + name.endsWith('.swiftmodule') + ).length + const cmoduleCount = readdirSync(join(vendored, 'cmodules')).length + const xcframework = join(vendored, 'libZcashDeps.xcframework') + if (!existsSync(join(xcframework, 'Info.plist'))) { + throw new Error(`Missing or incomplete ${xcframework}`) + } + if (moduleCount < 10 || cmoduleCount < 5) { + throw new Error( + `Vendored module harvest looks incomplete: ${moduleCount} swiftmodules, ${cmoduleCount} cmodules` + ) + } +} + +function loud(cwd: string, argv: string[]): void { + execFileSync(argv[0], argv.slice(1), { + cwd, + stdio: 'inherit', + encoding: 'utf8' + }) +} 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 92249df3..9cc6299f 100644 --- a/scripts/updateSources.ts +++ b/scripts/updateSources.ts @@ -9,6 +9,7 @@ import { deepList, justFiles, makeNodeDisklet, navigateDisklet } from 'disklet' import { existsSync, mkdirSync, readFileSync } from 'fs' import { join } from 'path' +import { buildVendoredDeps } from './buildVendoredDeps' import { copyCheckpoints } from './copyCheckpoints' const disklet = makeNodeDisklet(join(__dirname, '../')) @@ -20,29 +21,43 @@ async function main(): Promise { await rebuildXcframework() await copySwift() await copyCheckpoints(disklet) + // grpc-swift (1.24+) and SwiftNIO are SwiftPM-only with no podspec, so the + // deps the vendored SDK source links against are pre-built into a static + // binary instead of being CocoaPods dependencies. + 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.5.2' +// 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 +// 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 = - '27089796e15eacd0e5a90e7ea01884ea5c40806cf25a6fa9a6aca933dad65813' + 'c012c2b682191f027c1874ecde84adeeaef26dbb3e827dd5f29deb0eb8af0ef2' function downloadSources(): void { getRepo( 'ZcashLightClientKit', 'https://github.com/zcash/zcash-swift-wallet-sdk.git', - // 2.5.2: - 'e725a2482dced83afda91bcebe881bd0791aa359' + 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. } /** @@ -58,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]) @@ -109,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. */ @@ -167,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 +}