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 71afe48c..9f19d487 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 @@ -33,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" @@ -57,13 +65,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,11 +104,39 @@ class RNZcashModule( args.putString("name", status.toString()) } } - wallet.transactions.collectWith(scope) { txList -> + 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) @@ -104,27 +146,15 @@ class RNZcashModule( sendEvent("TransactionEvent") { args -> args.putString("alias", alias) - args.putArray( - "transactions", - nativeArray, - ) + args.putArray("transactions", nativeArray) } } } - 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) @@ -188,11 +218,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) + } } } @@ -204,19 +238,20 @@ 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()) + map.putBoolean("isShielding", tx.isShielding) + map.putBoolean("isExpired", tx.transactionState == TransactionState.Expired) + 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 +275,15 @@ class RNZcashModule( promise: Promise, ) { val wallet = getWallet(alias) - wallet.coroutineScope.launch { - promise.wrap { - wallet.rewindToNearestHeight(wallet.latestBirthdayHeight) - return@wrap null - } + moduleScope.launch { + // Clear emitted transactions tracking and starting block height for this alias + emittedTransactions[alias]?.clear() + + wallet.coroutineScope + .async { + wallet.rewindToNearestHeight(wallet.latestBirthdayHeight) + }.await() + promise.resolve(null) } } @@ -303,6 +342,10 @@ class RNZcashModule( response.toThrowable(), ) } + + else -> { + throw Exception("Unknown response type") + } } } } @@ -319,9 +362,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 +393,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,21 +426,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() - 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) + 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) } @@ -409,16 +469,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) } } } @@ -474,10 +537,4 @@ class RNZcashModule( .getJSModule(RCTDeviceEventEmitter::class.java) .emit(eventName, args) } - - data class Balances( - val transparentBalance: Zatoshi?, - val saplingBalances: WalletBalance?, - val orchardBalances: WalletBalance?, - ) } 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..a21e51f6 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 @@ -14,6 +31,8 @@ struct ConfirmedTx { var blockTimeInSeconds: Int var value: String var fee: String? + var isShielding: Bool + var isExpired: Bool var memos: [String]? var dictionary: [String: Any?] { return [ @@ -25,28 +44,8 @@ struct ConfirmedTx { "value": value, "fee": fee, "memos": memos ?? [], - ] - } - var nsDictionary: NSDictionary { - return dictionary as NSDictionary - } -} - -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), + "isShielding": isShielding, + "isExpired": isExpired, ] } var nsDictionary: NSDictionary { @@ -109,9 +108,11 @@ 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 SynchronizerMap[alias] == nil { + if await synchronizerStore.get(alias) == nil { do { let wallet = try WalletSynchronizer( alias: alias, initializer: initializer, emitter: sendToJs) @@ -121,11 +122,16 @@ 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() - SynchronizerMap[alias] = wallet + await synchronizerStore.set(wallet, for: alias) resolve(nil) } catch { reject("InitializeError", "Synchronizer failed to initialize", error) @@ -137,45 +143,28 @@ class RNZcash: RCTEventEmitter { } } - @objc func start( + @objc func stop( _ 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) - } + if let wallet = await synchronizerStore.get(alias) { + wallet.synchronizer.stop() + wallet.cancellables.forEach { $0.cancel() } + await synchronizerStore.remove(alias) resolve(nil) } else { - reject("StartError", "Wallet does not exist", genericError) + reject("StopError", "Wallet does not exist", genericError) } } } - @objc func stop( - _ alias: String, resolver resolve: @escaping RCTPromiseResolveBlock, - rejecter reject: @escaping RCTPromiseRejectBlock - ) { - if let wallet = SynchronizerMap[alias] { - wallet.synchronizer.stop() - wallet.cancellables.forEach { $0.cancel() } - SynchronizerMap[alias] = nil - resolve(nil) - } else { - reject("StopError", "Wallet does not exist", genericError) - } - } - @objc func getLatestNetworkHeight( _ alias: String, resolver resolve: @escaping RCTPromiseResolveBlock, 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) @@ -197,8 +186,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) @@ -212,7 +201,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) @@ -224,8 +213,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 @@ -259,7 +252,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)! @@ -278,7 +271,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)", @@ -304,7 +297,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 @@ -315,17 +308,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 ) - var confTx = await wallet.parseTx(tx: tx) + if let result = try await stream.first(where: { _ in true }) { + switch result { + case .success(let txId): + resolve(txId.toHexStringTxId()) + return - // Hack: Memos aren't ready to be queried right after broadcast - confTx.memos = [memo] - resolve(confTx.nsDictionary) + 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) } @@ -340,7 +368,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 { @@ -353,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) @@ -381,7 +411,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 } @@ -412,12 +443,18 @@ 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) + 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, @@ -472,6 +509,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 @@ -479,7 +517,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 @@ -492,13 +529,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() { @@ -567,7 +597,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 @@ -598,19 +628,22 @@ 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) { - 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 @@ -621,16 +654,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) } @@ -639,7 +671,9 @@ 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)), + isShielding: tx.isShielding, + isExpired: tx.isExpiredUmined ?? false ) if tx.raw != nil { confTx.raw = tx.raw!.hexEncodedString() @@ -651,7 +685,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 @@ -676,7 +710,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) } @@ -687,6 +720,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( 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, diff --git a/src/types.ts b/src/types.ts index a4c22fea..9affcf1e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -102,6 +102,8 @@ export interface Transaction { value: string fee?: string toAddress?: string + isShielding: boolean + isExpired: boolean memos: string[] }