From 8e216ea4ac09b8fb3eab15964ab9329e3fd6322d Mon Sep 17 00:00:00 2001 From: Matthew Date: Fri, 14 Nov 2025 10:24:06 -0800 Subject: [PATCH 01/11] Remove unused start method --- ios/RNZcash.m | 5 ----- ios/RNZcash.swift | 19 ------------------- 2 files changed, 24 deletions(-) diff --git a/ios/RNZcash.m b/ios/RNZcash.m index 7acf3f9c..dee79f59 100644 --- a/ios/RNZcash.m +++ b/ios/RNZcash.m @@ -15,11 +15,6 @@ @interface RCT_EXTERN_MODULE(RNZcash, RCTEventEmitter) rejecter:(RCTPromiseRejectBlock)reject ) -RCT_EXTERN_METHOD(start:(NSString *)alias -resolver:(RCTPromiseResolveBlock)resolve -rejecter:(RCTPromiseRejectBlock)reject -) - RCT_EXTERN_METHOD(stop:(NSString *)alias resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject diff --git a/ios/RNZcash.swift b/ios/RNZcash.swift index 32830b34..79c63c32 100644 --- a/ios/RNZcash.swift +++ b/ios/RNZcash.swift @@ -137,25 +137,6 @@ class RNZcash: RCTEventEmitter { } } - @objc func start( - _ alias: String, resolver resolve: @escaping RCTPromiseResolveBlock, - rejecter reject: @escaping RCTPromiseRejectBlock - ) { - Task { - if let wallet = SynchronizerMap[alias] { - do { - try await wallet.synchronizer.start() - wallet.subscribe() - } catch { - reject("StartError", "Synchronizer failed to start", error) - } - resolve(nil) - } else { - reject("StartError", "Wallet does not exist", genericError) - } - } - } - @objc func stop( _ alias: String, resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock From 8fd1dd2e9a27be8760a3ae6a0689966593f81ed3 Mon Sep 17 00:00:00 2001 From: Matthew Date: Fri, 14 Nov 2025 12:28:17 -0800 Subject: [PATCH 02/11] Replace synchronizerMap with thread-safe synchronizerStore --- ios/RNZcash.swift | 41 +++++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/ios/RNZcash.swift b/ios/RNZcash.swift index 79c63c32..80e48954 100644 --- a/ios/RNZcash.swift +++ b/ios/RNZcash.swift @@ -4,7 +4,24 @@ import MnemonicSwift import SwiftProtobuf import os -var SynchronizerMap = [String: WalletSynchronizer]() +actor SynchronizerStore { + private var map: [String: WalletSynchronizer] = [:] + + func get(_ alias: String) -> WalletSynchronizer? { + map[alias] + } + + func set(_ wallet: WalletSynchronizer, for alias: String) { + map[alias] = wallet + } + + func remove(_ alias: String) { + map.removeValue(forKey: alias) + } + +} + +let synchronizerStore = SynchronizerStore() struct ConfirmedTx { var minedHeight: Int @@ -111,7 +128,7 @@ class RNZcash: RCTEventEmitter { saplingParamsSourceURL: SaplingParamsSourceURL.default, alias: ZcashSynchronizerAlias.custom(alias) ) - if SynchronizerMap[alias] == nil { + if await synchronizerStore.get(alias) == nil { do { let wallet = try WalletSynchronizer( alias: alias, initializer: initializer, emitter: sendToJs) @@ -125,7 +142,7 @@ class RNZcash: RCTEventEmitter { ) try await wallet.synchronizer.start() wallet.subscribe() - SynchronizerMap[alias] = wallet + await synchronizerStore.set(wallet, for: alias) resolve(nil) } catch { reject("InitializeError", "Synchronizer failed to initialize", error) @@ -140,11 +157,11 @@ class RNZcash: RCTEventEmitter { @objc func stop( _ alias: String, resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock - ) { - if let wallet = SynchronizerMap[alias] { + ) async { + if let wallet = await synchronizerStore.get(alias) { wallet.synchronizer.stop() wallet.cancellables.forEach { $0.cancel() } - SynchronizerMap[alias] = nil + await synchronizerStore.remove(alias) resolve(nil) } else { reject("StopError", "Wallet does not exist", genericError) @@ -156,7 +173,7 @@ class RNZcash: RCTEventEmitter { rejecter reject: @escaping RCTPromiseRejectBlock ) { Task { - if let wallet = SynchronizerMap[alias] { + if let wallet = await synchronizerStore.get(alias) { do { let height = try await wallet.synchronizer.latestHeight() resolve(height) @@ -193,7 +210,7 @@ class RNZcash: RCTEventEmitter { rejecter reject: @escaping RCTPromiseRejectBlock ) { Task { - if let wallet = SynchronizerMap[alias] { + if let wallet = await synchronizerStore.get(alias) { let amount = Int64(zatoshi) if amount == nil { reject("ProposeTransferError", "Amount is invalid", genericError) @@ -240,7 +257,7 @@ class RNZcash: RCTEventEmitter { rejecter reject: @escaping RCTPromiseRejectBlock ) { Task { - if let wallet = SynchronizerMap[alias] { + if let wallet = await synchronizerStore.get(alias) { do { let spendingKey = try deriveUnifiedSpendingKey(seed, wallet.synchronizer.network) let data = Data.init(base64Encoded: proposalBase64)! @@ -285,7 +302,7 @@ class RNZcash: RCTEventEmitter { rejecter reject: @escaping RCTPromiseRejectBlock ) { Task { - if let wallet = SynchronizerMap[alias] { + if let wallet = await synchronizerStore.get(alias) { if !wallet.fullySynced { reject("shieldFunds", "Wallet is not synced", genericError) return @@ -321,7 +338,7 @@ class RNZcash: RCTEventEmitter { rejecter reject: @escaping RCTPromiseRejectBlock ) { Task { - if let wallet = SynchronizerMap[alias] { + if let wallet = await synchronizerStore.get(alias) { wallet.synchronizer.rewind(.birthday).sink( receiveCompletion: { completion in Task { @@ -393,7 +410,7 @@ class RNZcash: RCTEventEmitter { rejecter reject: @escaping RCTPromiseRejectBlock ) { Task { - if let wallet = SynchronizerMap[alias] { + if let wallet = await synchronizerStore.get(alias) { do { let unifiedAddress = try await wallet.synchronizer.getUnifiedAddress(accountIndex: 0) let saplingAddress = try await wallet.synchronizer.getSaplingAddress(accountIndex: 0) From 3e2a299b5c5a5c74244d798727e51b0196785459 Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 17 Nov 2025 10:20:42 -0800 Subject: [PATCH 03/11] Remove balance obj on wallet No longer used and we can just create the dictionary as needed --- ios/RNZcash.swift | 56 ++++++++--------------------------------------- 1 file changed, 9 insertions(+), 47 deletions(-) diff --git a/ios/RNZcash.swift b/ios/RNZcash.swift index 80e48954..9ce62ba6 100644 --- a/ios/RNZcash.swift +++ b/ios/RNZcash.swift @@ -49,28 +49,6 @@ struct ConfirmedTx { } } -struct TotalBalances { - var transparentAvailableZatoshi: Zatoshi - var transparentTotalZatoshi: Zatoshi - var saplingAvailableZatoshi: Zatoshi - var saplingTotalZatoshi: Zatoshi - var orchardAvailableZatoshi: Zatoshi - var orchardTotalZatoshi: Zatoshi - var dictionary: [String: Any] { - return [ - "transparentAvailableZatoshi": String(transparentAvailableZatoshi.amount), - "transparentTotalZatoshi": String(transparentTotalZatoshi.amount), - "saplingAvailableZatoshi": String(saplingAvailableZatoshi.amount), - "saplingTotalZatoshi": String(saplingTotalZatoshi.amount), - "orchardAvailableZatoshi": String(orchardAvailableZatoshi.amount), - "orchardTotalZatoshi": String(orchardTotalZatoshi.amount), - ] - } - var nsDictionary: NSDictionary { - return dictionary as NSDictionary - } -} - struct ProcessorState { var scanProgress: Int var networkBlockHeight: Int @@ -477,7 +455,6 @@ class WalletSynchronizer: NSObject { var restart: Bool var processorState: ProcessorState var cancellables: [AnyCancellable] = [] - var balances: TotalBalances init(alias: String, initializer: Initializer, emitter: @escaping (String, Any) -> Void) throws { self.alias = alias @@ -490,13 +467,6 @@ class WalletSynchronizer: NSObject { scanProgress: 0, networkBlockHeight: 0 ) - self.balances = TotalBalances( - transparentAvailableZatoshi: Zatoshi(0), - transparentTotalZatoshi: Zatoshi(0), - saplingAvailableZatoshi: Zatoshi(0), - saplingTotalZatoshi: Zatoshi(0), - orchardAvailableZatoshi: Zatoshi(0), - orchardTotalZatoshi: Zatoshi(0)) } public func subscribe() { @@ -596,13 +566,6 @@ class WalletSynchronizer: NSObject { scanProgress: 0, networkBlockHeight: 0 ) - self.balances = TotalBalances( - transparentAvailableZatoshi: Zatoshi(0), - transparentTotalZatoshi: Zatoshi(0), - saplingAvailableZatoshi: Zatoshi(0), - saplingTotalZatoshi: Zatoshi(0), - orchardAvailableZatoshi: Zatoshi(0), - orchardTotalZatoshi: Zatoshi(0)) } func updateBalanceState(event: SynchronizerState) { @@ -619,16 +582,15 @@ class WalletSynchronizer: NSObject { let orchardAvailableZatoshi = orchardBalance.spendableValue let orchardTotalZatoshi = orchardBalance.total() - self.balances = TotalBalances( - transparentAvailableZatoshi: transparentAvailableZatoshi, - transparentTotalZatoshi: transparentTotalZatoshi, - saplingAvailableZatoshi: saplingAvailableZatoshi, - saplingTotalZatoshi: saplingTotalZatoshi, - orchardAvailableZatoshi: orchardAvailableZatoshi, - orchardTotalZatoshi: orchardTotalZatoshi - ) - let data = NSMutableDictionary(dictionary: self.balances.nsDictionary) - data["alias"] = self.alias + let data: NSDictionary = [ + "alias": self.alias, + "transparentAvailableZatoshi": String(transparentAvailableZatoshi.amount), + "transparentTotalZatoshi": String(transparentTotalZatoshi.amount), + "saplingAvailableZatoshi": String(saplingAvailableZatoshi.amount), + "saplingTotalZatoshi": String(saplingTotalZatoshi.amount), + "orchardAvailableZatoshi": String(orchardAvailableZatoshi.amount), + "orchardTotalZatoshi": String(orchardTotalZatoshi.amount), + ] emit("BalanceEvent", data) } From 17fd3580b7d43ab59b7c9d72b62d9daf049dbb78 Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 17 Nov 2025 10:25:55 -0800 Subject: [PATCH 04/11] Only return txid when shielding transaction is successful --- android/src/main/java/app/edge/rnzcash/RNZcashModule.kt | 8 +------- ios/RNZcash.swift | 7 +------ src/react-native.ts | 5 ++--- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt b/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt index 71afe48c..289f6d50 100644 --- a/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt +++ b/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt @@ -385,13 +385,7 @@ class RNZcashModule( memo, ) val tx = wallet.coroutineScope.async { wallet.transactions.first().first() }.await() - val parsedTx = parseTx(wallet, tx) - - // Hack: Memos aren't ready to be queried right after broadcast - val memos = Arguments.createArray() - memos.pushString(memo) - parsedTx.putArray("memos", memos) - promise.resolve(parsedTx) + promise.resolve(tx.txIdString()) } catch (t: Throwable) { promise.reject("Err", t) } diff --git a/ios/RNZcash.swift b/ios/RNZcash.swift index 9ce62ba6..f3a7ee0e 100644 --- a/ios/RNZcash.swift +++ b/ios/RNZcash.swift @@ -296,12 +296,7 @@ class RNZcash: RCTEventEmitter { memo: sdkMemo, shieldingThreshold: Zatoshi(shieldingThreshold) ) - - var confTx = await wallet.parseTx(tx: tx) - - // Hack: Memos aren't ready to be queried right after broadcast - confTx.memos = [memo] - resolve(confTx.nsDictionary) + resolve(tx.rawID.toHexStringTxId()) } catch { reject("shieldFunds", "Failed to shield funds", genericError) } diff --git a/src/react-native.ts b/src/react-native.ts index a5aa87a8..0f0c6a39 100644 --- a/src/react-native.ts +++ b/src/react-native.ts @@ -14,8 +14,7 @@ import { ProposeTransferOpts, ShieldFundsInfo, SpendFailure, - SynchronizerCallbacks, - Transaction + SynchronizerCallbacks } from './types' export * from './types' @@ -110,7 +109,7 @@ export class Synchronizer { return result } - async shieldFunds(shieldFundsInfo: ShieldFundsInfo): Promise { + async shieldFunds(shieldFundsInfo: ShieldFundsInfo): Promise { const result = await RNZcash.shieldFunds( this.alias, shieldFundsInfo.seed, From 06498a9fda1b6d79d2c439f084bfeab4b205d519 Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 17 Nov 2025 10:34:53 -0800 Subject: [PATCH 05/11] Upgrade ios library to latest --- ios/RNZcash.swift | 145 +++++++++++++++++++++++++++++++-------- scripts/updateSources.ts | 37 ++++++++-- 2 files changed, 149 insertions(+), 33 deletions(-) diff --git a/ios/RNZcash.swift b/ios/RNZcash.swift index f3a7ee0e..770235e5 100644 --- a/ios/RNZcash.swift +++ b/ios/RNZcash.swift @@ -104,7 +104,9 @@ class RNZcash: RCTEventEmitter { spendParamsURL: try! spendParamsURLHelper(alias), outputParamsURL: try! outputParamsURLHelper(alias), saplingParamsSourceURL: SaplingParamsSourceURL.default, - alias: ZcashSynchronizerAlias.custom(alias) + alias: ZcashSynchronizerAlias.custom(alias), + isTorEnabled: false, + isExchangeRateEnabled: false ) if await synchronizerStore.get(alias) == nil { do { @@ -116,8 +118,13 @@ class RNZcash: RCTEventEmitter { _ = try await wallet.synchronizer.prepare( with: seedBytes, walletBirthday: birthdayHeight, - for: initMode + for: initMode, + name: alias, + keySource: nil ) + let accounts = try await wallet.synchronizer.listAccounts() + let accountUUID = accounts.first(where: { $0.name == alias })?.id + wallet.accountUUID = accountUUID try await wallet.synchronizer.start() wallet.subscribe() await synchronizerStore.set(wallet, for: alias) @@ -135,14 +142,16 @@ class RNZcash: RCTEventEmitter { @objc func stop( _ alias: String, resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock - ) async { - if let wallet = await synchronizerStore.get(alias) { - wallet.synchronizer.stop() - wallet.cancellables.forEach { $0.cancel() } - await synchronizerStore.remove(alias) - resolve(nil) - } else { - reject("StopError", "Wallet does not exist", genericError) + ) { + Task { + if let wallet = await synchronizerStore.get(alias) { + wallet.synchronizer.stop() + wallet.cancellables.forEach { $0.cancel() } + await synchronizerStore.remove(alias) + resolve(nil) + } else { + reject("StopError", "Wallet does not exist", genericError) + } } } @@ -173,8 +182,8 @@ class RNZcash: RCTEventEmitter { do { let endpoint = LightWalletEndpoint(address: host, port: port, secure: true) let lightwalletd: LightWalletService = LightWalletGRPCService(endpoint: endpoint) - let height = try await lightwalletd.latestBlockHeight() - lightwalletd.closeConnection() + let height = try await lightwalletd.latestBlockHeight(mode: ServiceMode.direct) + await lightwalletd.closeConnections() resolve(height) } catch { reject("getLatestNetworkHeightGrpc", "Failed to query blockheight", error) @@ -200,8 +209,12 @@ class RNZcash: RCTEventEmitter { if memo != "" { sdkMemo = try Memo(string: memo) } + guard let accountUUID = wallet.accountUUID else { + reject("ProposeTransferError", "Account UUID not found", genericError) + return + } let proposal = try await wallet.synchronizer.proposeTransfer( - accountIndex: 0, + accountUUID: accountUUID, recipient: Recipient(toAddress, network: wallet.synchronizer.network.networkType), amount: Zatoshi(amount!), memo: sdkMemo @@ -254,7 +267,7 @@ class RNZcash: RCTEventEmitter { case .success(let txId): lastTxid = txId.toHexStringTxId() continue - case let .submitFailure(txId: _, code: code, description: description): + case .submitFailure(txId: _, let code, let description): throw NSError( domain: "transaction failed to submit with code: \(code) - description: \(description)", @@ -291,12 +304,52 @@ class RNZcash: RCTEventEmitter { let sdkMemo = try Memo(string: memo) let shieldingThreshold = Int64(threshold) ?? 10000 - let tx = try await wallet.synchronizer.shieldFunds( - spendingKey: spendingKey, - memo: sdkMemo, - shieldingThreshold: Zatoshi(shieldingThreshold) + guard let accountUUID = wallet.accountUUID else { + reject("shieldFunds", "Account UUID not found", genericError) + return + } + + guard + let proposal = try await wallet.synchronizer.proposeShielding( + accountUUID: accountUUID, + shieldingThreshold: Zatoshi(shieldingThreshold), + memo: sdkMemo + ) + else { + throw NSError( + domain: "shieldFunds", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Proposal was nil"] + ) + + } + + let stream = try await wallet.synchronizer.createProposedTransactions( + proposal: proposal, + spendingKey: spendingKey ) - resolve(tx.rawID.toHexStringTxId()) + + if let result = try await stream.first(where: { _ in true }) { + switch result { + case .success(let txId): + resolve(txId.toHexStringTxId()) + return + + default: + throw NSError( + domain: "shieldFunds", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed: \(result)"] + ) + } + } else { + // Stream ended without producing any result + throw NSError( + domain: "shieldFunds", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "No result returned"] + ) + } } catch { reject("shieldFunds", "Failed to shield funds", genericError) } @@ -352,7 +405,8 @@ class RNZcash: RCTEventEmitter { { let derivationTool = DerivationTool(networkType: network.networkType) let seedBytes = try Mnemonic.deterministicSeedBytes(from: seed) - let spendingKey = try derivationTool.deriveUnifiedSpendingKey(seed: seedBytes, accountIndex: 0) + let spendingKey = try derivationTool.deriveUnifiedSpendingKey( + seed: seedBytes, accountIndex: Zip32AccountIndex(0)) return spendingKey } @@ -385,10 +439,16 @@ class RNZcash: RCTEventEmitter { Task { if let wallet = await synchronizerStore.get(alias) { do { - let unifiedAddress = try await wallet.synchronizer.getUnifiedAddress(accountIndex: 0) - let saplingAddress = try await wallet.synchronizer.getSaplingAddress(accountIndex: 0) + guard let accountUUID = wallet.accountUUID else { + reject("deriveUnifiedAddress", "Account UUID not found", genericError) + return + } + let unifiedAddress = try await wallet.synchronizer.getUnifiedAddress( + accountUUID: accountUUID) + let saplingAddress = try await wallet.synchronizer.getSaplingAddress( + accountUUID: accountUUID) let transparentAddress = try await wallet.synchronizer.getTransparentAddress( - accountIndex: 0) + accountUUID: accountUUID) let addresses: NSDictionary = [ "unifiedAddress": unifiedAddress.stringEncoded, "saplingAddress": saplingAddress.stringEncoded, @@ -443,6 +503,7 @@ class RNZcash: RCTEventEmitter { class WalletSynchronizer: NSObject { public var alias: String + public var accountUUID: AccountUUID? public var synchronizer: SDKSynchronizer var status: String var emit: (String, Any) -> Void @@ -530,7 +591,7 @@ class WalletSynchronizer: NSObject { var scanProgress = 0 switch event.internalSyncStatus { - case .syncing(let progress): + case .syncing(let progress, _): scanProgress = Int(floor(progress * 100)) case .synced: scanProgress = 100 @@ -564,9 +625,19 @@ class WalletSynchronizer: NSObject { } func updateBalanceState(event: SynchronizerState) { - let transparentBalance = event.accountBalance?.unshielded ?? Zatoshi(0) - let shieldedBalance = event.accountBalance?.saplingBalance ?? PoolBalance.zero - let orchardBalance = event.accountBalance?.orchardBalance ?? PoolBalance.zero + guard let accountUUID = self.accountUUID else { + return + } + + // Safely check if the account exists in the balances dictionary + guard let accountBalance = event.accountsBalances[accountUUID] else { + return + } + + // Account exists, safely access the balance properties + let transparentBalance = accountBalance.unshielded + let shieldedBalance = accountBalance.saplingBalance + let orchardBalance = accountBalance.orchardBalance let transparentAvailableZatoshi = transparentBalance let transparentTotalZatoshi = transparentBalance @@ -594,7 +665,7 @@ class WalletSynchronizer: NSObject { minedHeight: tx.minedHeight ?? 0, rawTransactionId: (tx.rawID.toHexStringTxId()), blockTimeInSeconds: Int(tx.blockTime ?? 0), - value: String(describing: abs(tx.value.amount)) + value: String(describing: abs(tx.value.amount)), ) if tx.raw != nil { confTx.raw = tx.raw!.hexEncodedString() @@ -606,7 +677,7 @@ class WalletSynchronizer: NSObject { let recipients = await self.synchronizer.getRecipients(for: tx) if recipients.count > 0 { let addresses = recipients.compactMap { - if case let .address(address) = $0 { + if case .address(let address) = $0 { return address } else { return nil @@ -642,6 +713,22 @@ class WalletSynchronizer: NSObject { } } +extension AccountUUID { + static func fromAlias(_ alias: String) -> AccountUUID { + let bytes = Array(alias.utf8) + let folded = foldTo16Bytes(bytes) + return AccountUUID(id: folded) + } +} + +func foldTo16Bytes(_ bytes: [UInt8]) -> [UInt8] { + var out = [UInt8](repeating: 0, count: 16) + for (i, b) in bytes.enumerated() { + out[i % 16] ^= b + } + return out +} + // Local file helper funcs func documentsDirectoryHelper() throws -> URL { try FileManager.default.url( diff --git a/scripts/updateSources.ts b/scripts/updateSources.ts index e9c8b2a2..c14d818d 100644 --- a/scripts/updateSources.ts +++ b/scripts/updateSources.ts @@ -25,14 +25,14 @@ function downloadSources(): void { getRepo( 'ZcashLightClientKit', 'https://github.com/Electric-Coin-Company/zcash-swift-wallet-sdk.git', - // 2.2.6: - 'ce25f074b480d46d76c1fc20f0f0fe37d879a2c9' + // 2.4.0: + '1cf8a2375264995224f8282eaf63931439c28368' ) getRepo( 'zcash-light-client-ffi', 'https://github.com/Electric-Coin-Company/zcash-light-client-ffi.git', - // 0.10.2: - '7029804dc30d33b689fb8da712c1172daf8e402e' + // 0.19.0: + 'a7211faa2cea15db017fa138043ba712f61724a2' ) } @@ -95,6 +95,35 @@ async function copySwift(): Promise { // We are lumping everything into one module, // so we don't need to import this externally: .replace('import libzcashlc', '') + + // Rename Swift struct to avoid conflict with C struct from zcashlc.h + .replace( + /public struct ConfirmationsPolicy\s*\{/g, + 'public struct SwiftConfirmationsPolicy {' + ) + .replace( + /\bConfirmationsPolicy\.init\(/g, + 'SwiftConfirmationsPolicy.init(' + ) + .replace( + /\bConfirmationsPolicy\.default/g, + 'SwiftConfirmationsPolicy.default' + ) + .replace(/:\s*ConfirmationsPolicy\s*=/g, ': SwiftConfirmationsPolicy =') + .replace(/\bConfirmationsPolicy\s*:/g, 'SwiftConfirmationsPolicy:') + .replace(/libzcashlc\.ConfirmationsPolicy/g, 'ConfirmationsPolicy') + + // Replace serializedBytes with serializedData + .replace( + /LightdInfo\(serializedBytes:\s*data\)/g, + 'LightdInfo(serializedData: data)' + ) + .replace( + /TreeState\(serializedBytes:\s*data\)/g, + 'TreeState(serializedData: data)' + ) + .replace(/FfiProposal\(serializedBytes:/g, 'FfiProposal(serializedData:') + // The Swift package manager synthesizes a "Bundle.module" accessor, // but with CocoaPods we need to load things manually: .replace( From 83ce7d838fd7f0983a6400d86393b65e91cf2c00 Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 17 Nov 2025 10:38:20 -0800 Subject: [PATCH 06/11] Upgrade android sdk to v2.4.0 --- CHANGELOG.md | 2 + android/build.gradle | 12 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- .../java/app/edge/rnzcash/RNZcashModule.kt | 124 ++++++++++-------- 4 files changed, 81 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3726bdb..ab72683f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- changed: Upgrade iOS and Android sdks to v2.4.0 + ## 0.9.13 (2025-11-04) - changed: Updated checkpoints diff --git a/android/build.gradle b/android/build.gradle index 3a39e136..76017d51 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,14 +1,14 @@ buildscript { def kotlinVersion = rootProject.ext.has('kotlinVersion') ? rootProject.ext.get('kotlinVersion') - : '1.9.23' + : '2.1.10' repositories { mavenCentral() google() } dependencies { - classpath 'com.android.tools.build:gradle:7.3.1' + classpath 'com.android.tools.build:gradle:8.5.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}" } } @@ -22,10 +22,10 @@ def safeExtGet(prop, fallback) { } android { - compileSdkVersion safeExtGet('compileSdkVersion', 32) + compileSdkVersion safeExtGet('compileSdkVersion', 35) defaultConfig { minSdkVersion safeExtGet('minSdkVersion', 27) - targetSdkVersion safeExtGet('targetSdkVersion', 32) + targetSdkVersion safeExtGet('targetSdkVersion', 35) } lintOptions { abortOnError false @@ -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.2.5' - implementation 'cash.z.ecc.android:zcash-android-sdk-incubator:2.2.5' + implementation 'cash.z.ecc.android:zcash-android-sdk:2.4.0' + implementation 'cash.z.ecc.android:zcash-android-sdk-incubator:2.4.0' implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3" } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index d63a4e1a..61f85294 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https://services.gradle.org/distributions/gradle-7.5.1-all.zip +distributionUrl=https://services.gradle.org/distributions/gradle-8.11.1-all.zip diff --git a/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt b/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt index 289f6d50..f42e19ba 100644 --- a/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt +++ b/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt @@ -12,7 +12,6 @@ import cash.z.ecc.android.sdk.type.* import co.electriccoin.lightwallet.client.LightWalletClient import co.electriccoin.lightwallet.client.model.LightWalletEndpoint import co.electriccoin.lightwallet.client.model.Response -import co.electriccoin.lightwallet.client.new import com.facebook.react.bridge.* import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter import kotlinx.coroutines.CoroutineScope @@ -57,13 +56,19 @@ class RNZcashModule( if (!synchronizerMap.containsKey(alias)) { synchronizerMap[alias] = Synchronizer.new( - reactApplicationContext, - network, alias, - endpoint, - seedPhrase.toByteArray(), BlockHeight.new(birthdayHeight.toLong()), + reactApplicationContext, + endpoint, + AccountCreateSetup( + accountName = alias, + keySource = null, + seed = FirstClassByteArray(seedPhrase.toByteArray()), + ), initMode, + network, + false, // isTorEnabled + false, // isExchangeRateEnabled ) as SdkSynchronizer } val wallet = getWallet(alias) @@ -90,7 +95,7 @@ class RNZcashModule( args.putString("name", status.toString()) } } - wallet.transactions.collectWith(scope) { txList -> + wallet.allTransactions.collectWith(scope) { txList -> scope.launch { val nativeArray = Arguments.createArray() txList @@ -111,20 +116,11 @@ class RNZcashModule( } } } - combine( - wallet.transparentBalance, - wallet.saplingBalances, - wallet.orchardBalances, - ) { transparentBalance: Zatoshi?, saplingBalances: WalletBalance?, orchardBalances: WalletBalance? -> - return@combine Balances( - transparentBalance = transparentBalance, - saplingBalances = saplingBalances, - orchardBalances = orchardBalances, - ) - }.collectWith(scope) { map -> - val transparentBalance = map.transparentBalance - val saplingBalances = map.saplingBalances - val orchardBalances = map.orchardBalances + wallet.walletBalances.collectWith(scope) { balancesMap -> + val accountBalance = balancesMap?.values?.firstOrNull() + val transparentBalance = accountBalance?.unshielded + val saplingBalances = accountBalance?.sapling + val orchardBalances = accountBalance?.orchard val transparentAvailableZatoshi = transparentBalance ?: Zatoshi(0L) val transparentTotalZatoshi = transparentBalance ?: Zatoshi(0L) @@ -204,19 +200,18 @@ class RNZcashModule( val job = wallet.coroutineScope.launch { map.putString("value", tx.netValue.value.toString()) - if (tx.feePaid != null) { - map.putString("fee", tx.feePaid!!.value.toString()) - } + tx.feePaid?.let { fee -> map.putString("fee", fee.value.toString()) } map.putInt("minedHeight", tx.minedHeight?.value?.toInt() ?: 0) map.putInt("blockTimeInSeconds", tx.blockTimeEpochSeconds?.toInt() ?: 0) - map.putString("rawTransactionId", tx.txIdString()) - if (tx.raw != null) { - map.putString("raw", tx.raw!!.byteArray.toHex()) - } + map.putString("rawTransactionId", tx.txId.txIdString()) + tx.raw + ?.byteArray + ?.toHex() + ?.let { hex -> map.putString("raw", hex) } if (tx.isSentTransaction) { try { val recipient = wallet.getRecipients(tx).first() - if (recipient is TransactionRecipient.Address) { + if (recipient.addressValue != null) { map.putString("toAddress", recipient.addressValue) } } catch (t: Throwable) { @@ -240,11 +235,12 @@ class RNZcashModule( promise: Promise, ) { val wallet = getWallet(alias) - wallet.coroutineScope.launch { - promise.wrap { - wallet.rewindToNearestHeight(wallet.latestBirthdayHeight) - return@wrap null - } + moduleScope.launch { + wallet.coroutineScope + .async { + wallet.rewindToNearestHeight(wallet.latestBirthdayHeight) + }.await() + promise.resolve(null) } } @@ -303,6 +299,10 @@ class RNZcashModule( response.toThrowable(), ) } + + else -> { + throw Exception("Unknown response type") + } } } } @@ -319,9 +319,10 @@ class RNZcashModule( val wallet = getWallet(alias) wallet.coroutineScope.launch { try { + val account = wallet.getAccounts().first() val proposal = wallet.proposeTransfer( - Account.DEFAULT, + account, toAddress, Zatoshi(zatoshi.toLong()), memo, @@ -349,7 +350,12 @@ class RNZcashModule( wallet.coroutineScope.launch { try { val seedPhrase = SeedPhrase.new(seed) - val usk = DerivationTool.getInstance().deriveUnifiedSpendingKey(seedPhrase.toByteArray(), wallet.network, Account.DEFAULT) + val usk = + DerivationTool.getInstance().deriveUnifiedSpendingKey( + seedPhrase.toByteArray(), + wallet.network, + Zip32AccountIndex.new(0), + ) val proposalByteArray = Base64.getDecoder().decode(proposalBase64) val proposal = Proposal.fromByteArray(proposalByteArray) @@ -377,15 +383,32 @@ class RNZcashModule( val wallet = getWallet(alias) wallet.coroutineScope.launch { try { + val account = wallet.getAccounts().first() + val proposal = wallet.proposeShielding(account, Zatoshi(threshold.toLong()), memo, null) + if (proposal == null) { + promise.reject("Err", Exception("Failed to propose shielding transaction")) + return@launch + } val seedPhrase = SeedPhrase.new(seed) - val usk = DerivationTool.getInstance().deriveUnifiedSpendingKey(seedPhrase.toByteArray(), wallet.network, Account.DEFAULT) - val internalId = - wallet.shieldFunds( + val usk = + DerivationTool.getInstance().deriveUnifiedSpendingKey( + seedPhrase.toByteArray(), + wallet.network, + Zip32AccountIndex.new(0), + ) + val result = + wallet.createProposedTransactions( + proposal, usk, - memo, ) - val tx = wallet.coroutineScope.async { wallet.transactions.first().first() }.await() - promise.resolve(tx.txIdString()) + val shieldingTx = result.first() + + if (shieldingTx is TransactionSubmitResult.Success) { + val shieldingTxid = shieldingTx.txIdString() + promise.resolve(shieldingTxid) + } else { + promise.reject("Err", Exception("Failed to create shielding transaction")) + } } catch (t: Throwable) { promise.reject("Err", t) } @@ -403,16 +426,19 @@ class RNZcashModule( ) { val wallet = getWallet(alias) wallet.coroutineScope.launch { - promise.wrap { - val unifiedAddress = wallet.getUnifiedAddress(Account(0)) - val saplingAddress = wallet.getSaplingAddress(Account(0)) - val transparentAddress = wallet.getTransparentAddress(Account(0)) + try { + val account = wallet.getAccounts().first() + val unifiedAddress = wallet.getUnifiedAddress(account) + val saplingAddress = wallet.getSaplingAddress(account) + val transparentAddress = wallet.getTransparentAddress(account) val map = Arguments.createMap() map.putString("unifiedAddress", unifiedAddress) map.putString("saplingAddress", saplingAddress) map.putString("transparentAddress", transparentAddress) - return@wrap map + promise.resolve(map) + } catch (t: Throwable) { + promise.reject("Err", t) } } } @@ -468,10 +494,4 @@ class RNZcashModule( .getJSModule(RCTDeviceEventEmitter::class.java) .emit(eventName, args) } - - data class Balances( - val transparentBalance: Zatoshi?, - val saplingBalances: WalletBalance?, - val orchardBalances: WalletBalance?, - ) } From 436e3bb2973f408e4f3851aa263b1bf24ee290c9 Mon Sep 17 00:00:00 2001 From: Matthew Date: Tue, 11 Nov 2025 23:50:59 -0800 Subject: [PATCH 07/11] android - Use async stop Use async close method to avoid starting again while still shutting down --- .../main/java/app/edge/rnzcash/RNZcashModule.kt | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt b/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt index f42e19ba..8f668439 100644 --- a/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt +++ b/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt @@ -184,11 +184,15 @@ class RNZcashModule( alias: String, promise: Promise, ) { - promise.wrap { - val wallet = getWallet(alias) - wallet.close() - synchronizerMap.remove(alias) - return@wrap null + val wallet = getWallet(alias) + moduleScope.launch { + try { + wallet.closeFlow().first() + synchronizerMap.remove(alias) + promise.resolve(null) + } catch (t: Throwable) { + promise.reject("Err", t) + } } } From 567784773a8f8bffeca1ec75e96720172cee7e00 Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 17 Nov 2025 10:40:13 -0800 Subject: [PATCH 08/11] Pass isShielding bool in transaction event --- android/src/main/java/app/edge/rnzcash/RNZcashModule.kt | 1 + ios/RNZcash.swift | 3 +++ src/types.ts | 1 + 3 files changed, 5 insertions(+) diff --git a/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt b/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt index 8f668439..ae550a16 100644 --- a/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt +++ b/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt @@ -208,6 +208,7 @@ class RNZcashModule( map.putInt("minedHeight", tx.minedHeight?.value?.toInt() ?: 0) map.putInt("blockTimeInSeconds", tx.blockTimeEpochSeconds?.toInt() ?: 0) map.putString("rawTransactionId", tx.txId.txIdString()) + map.putBoolean("isShielding", tx.isShielding) tx.raw ?.byteArray ?.toHex() diff --git a/ios/RNZcash.swift b/ios/RNZcash.swift index 770235e5..95996ad7 100644 --- a/ios/RNZcash.swift +++ b/ios/RNZcash.swift @@ -31,6 +31,7 @@ struct ConfirmedTx { var blockTimeInSeconds: Int var value: String var fee: String? + var isShielding: Bool var memos: [String]? var dictionary: [String: Any?] { return [ @@ -42,6 +43,7 @@ struct ConfirmedTx { "value": value, "fee": fee, "memos": memos ?? [], + "isShielding": isShielding, ] } var nsDictionary: NSDictionary { @@ -666,6 +668,7 @@ class WalletSynchronizer: NSObject { rawTransactionId: (tx.rawID.toHexStringTxId()), blockTimeInSeconds: Int(tx.blockTime ?? 0), value: String(describing: abs(tx.value.amount)), + isShielding: tx.isShielding, ) if tx.raw != nil { confTx.raw = tx.raw!.hexEncodedString() diff --git a/src/types.ts b/src/types.ts index a4c22fea..a6163f6a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -102,6 +102,7 @@ export interface Transaction { value: string fee?: string toAddress?: string + isShielding: boolean memos: string[] } From 709d1f3524e82aa51ed67c253d2b145c168cf96d Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 17 Nov 2025 12:05:43 -0800 Subject: [PATCH 09/11] Track transactions to only emit when seen or changed --- .../java/app/edge/rnzcash/RNZcashModule.kt | 49 ++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt b/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt index ae550a16..6ae9113b 100644 --- a/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt +++ b/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt @@ -32,6 +32,15 @@ class RNZcashModule( private var moduleScope: CoroutineScope = CoroutineScope(Dispatchers.IO) private var synchronizerMap = mutableMapOf() + // Track emitted transactions per alias to only emit new or updated transactions + private val emittedTransactions = mutableMapOf>() + + // Data class to track what we've emitted for each transaction + private data class EmittedTxState( + val minedHeight: BlockHeight?, + val transactionState: TransactionState, + ) + private val networks = mapOf("mainnet" to ZcashNetwork.Mainnet, "testnet" to ZcashNetwork.Testnet) override fun getName() = "RNZcash" @@ -97,9 +106,37 @@ class RNZcashModule( } wallet.allTransactions.collectWith(scope) { txList -> scope.launch { + // Get or create the tracking map for this alias + val emittedForAlias = emittedTransactions.getOrPut(alias) { mutableMapOf() } + + val transactionsToEmit = mutableListOf() + + txList.forEach { tx -> + val txId = tx.txId.txIdString() + val previousState = emittedForAlias[txId] + + // Check if this is a new transaction or if minedHeight/transactionState changed + val isNew = previousState == null + val minedHeightChanged = previousState?.minedHeight != tx.minedHeight + val stateChanged = previousState?.transactionState != tx.transactionState + + if (isNew || minedHeightChanged || stateChanged) { + transactionsToEmit.add(tx) + // Update our tracking + emittedForAlias[txId] = + EmittedTxState( + minedHeight = tx.minedHeight, + transactionState = tx.transactionState, + ) + } + } + + if (transactionsToEmit.isEmpty()) { + return@launch + } + val nativeArray = Arguments.createArray() - txList - .filter { tx -> tx.transactionState != TransactionState.Expired } + transactionsToEmit .map { tx -> launch { val parsedTx = parseTx(wallet, tx) @@ -109,10 +146,7 @@ class RNZcashModule( sendEvent("TransactionEvent") { args -> args.putString("alias", alias) - args.putArray( - "transactions", - nativeArray, - ) + args.putArray("transactions", nativeArray) } } } @@ -241,6 +275,9 @@ class RNZcashModule( ) { val wallet = getWallet(alias) moduleScope.launch { + // Clear emitted transactions tracking and starting block height for this alias + emittedTransactions[alias]?.clear() + wallet.coroutineScope .async { wallet.rewindToNearestHeight(wallet.latestBirthdayHeight) From 9d8f032426003efc243fd9eb33c4e14772d3441e Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 17 Nov 2025 13:21:36 -0800 Subject: [PATCH 10/11] Add isExpired flag to transactions --- android/src/main/java/app/edge/rnzcash/RNZcashModule.kt | 1 + ios/RNZcash.swift | 4 +++- src/types.ts | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt b/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt index 6ae9113b..9f19d487 100644 --- a/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt +++ b/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt @@ -243,6 +243,7 @@ class RNZcashModule( map.putInt("blockTimeInSeconds", tx.blockTimeEpochSeconds?.toInt() ?: 0) map.putString("rawTransactionId", tx.txId.txIdString()) map.putBoolean("isShielding", tx.isShielding) + map.putBoolean("isExpired", tx.transactionState == TransactionState.Expired) tx.raw ?.byteArray ?.toHex() diff --git a/ios/RNZcash.swift b/ios/RNZcash.swift index 95996ad7..3c5c52b3 100644 --- a/ios/RNZcash.swift +++ b/ios/RNZcash.swift @@ -32,6 +32,7 @@ struct ConfirmedTx { var value: String var fee: String? var isShielding: Bool + var isExpired: Bool var memos: [String]? var dictionary: [String: Any?] { return [ @@ -44,6 +45,7 @@ struct ConfirmedTx { "fee": fee, "memos": memos ?? [], "isShielding": isShielding, + "isExpired": isExpired, ] } var nsDictionary: NSDictionary { @@ -669,6 +671,7 @@ class WalletSynchronizer: NSObject { blockTimeInSeconds: Int(tx.blockTime ?? 0), value: String(describing: abs(tx.value.amount)), isShielding: tx.isShielding, + isExpired: tx.isExpiredUmined ?? false ) if tx.raw != nil { confTx.raw = tx.raw!.hexEncodedString() @@ -705,7 +708,6 @@ class WalletSynchronizer: NSObject { Task { var out: [NSDictionary] = [] for tx in transactions { - if tx.isExpiredUmined ?? false { continue } let confTx = await parseTx(tx: tx) out.append(confTx.nsDictionary) } diff --git a/src/types.ts b/src/types.ts index a6163f6a..9affcf1e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -103,6 +103,7 @@ export interface Transaction { fee?: string toAddress?: string isShielding: boolean + isExpired: boolean memos: string[] } From acfd00b4f153049fcc61d1af819809495fe1873b Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 17 Nov 2025 13:22:10 -0800 Subject: [PATCH 11/11] ios - resend transactions after resync The event handlers only listens to new transactions --- ios/RNZcash.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ios/RNZcash.swift b/ios/RNZcash.swift index 3c5c52b3..a21e51f6 100644 --- a/ios/RNZcash.swift +++ b/ios/RNZcash.swift @@ -381,6 +381,8 @@ class RNZcash: RCTEventEmitter { wallet.cancellables.forEach { $0.cancel() } try await wallet.synchronizer.start() wallet.subscribe() + let txs = try await wallet.synchronizer.allTransactions() + wallet.emitTxs(transactions: txs) resolve(nil) case .failure: reject("RescanError", "Failed to rescan wallet", genericError)