react-native-zcash 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +6 -3
- package/android/build.gradle +5 -4
- package/android/src/main/assets/co.electriccoin.zcash/checkpoint/mainnet/2230000.json +8 -0
- package/android/src/main/assets/co.electriccoin.zcash/checkpoint/mainnet/2240000.json +8 -0
- package/android/src/main/assets/co.electriccoin.zcash/checkpoint/mainnet/2250000.json +8 -0
- package/android/src/main/java/app/edge/rnzcash/RNZcashModule.kt +117 -122
- package/ios/RNZcash.m +1 -12
- package/ios/RNZcash.swift +127 -153
- package/ios/ZCashLightClientKit/Block/Actions/Action.swift +98 -0
- package/ios/ZCashLightClientKit/Block/Actions/ClearAlreadyScannedBlocksAction.swift +35 -0
- package/ios/ZCashLightClientKit/Block/Actions/ClearCacheAction.swift +30 -0
- package/ios/ZCashLightClientKit/Block/Actions/DownloadAction.swift +67 -0
- package/ios/ZCashLightClientKit/Block/Actions/EnhanceAction.swift +97 -0
- package/ios/ZCashLightClientKit/Block/Actions/FetchUTXOsAction.swift +33 -0
- package/ios/ZCashLightClientKit/Block/Actions/MigrateLegacyCacheDBAction.swift +70 -0
- package/ios/ZCashLightClientKit/Block/Actions/ProcessSuggestedScanRangesAction.swift +59 -0
- package/ios/ZCashLightClientKit/Block/Actions/RewindAction.swift +48 -0
- package/ios/ZCashLightClientKit/Block/Actions/SaplingParamsAction.swift +33 -0
- package/ios/ZCashLightClientKit/Block/Actions/ScanAction.swift +95 -0
- package/ios/ZCashLightClientKit/Block/Actions/UpdateChainTipAction.swift +55 -0
- package/ios/ZCashLightClientKit/Block/Actions/UpdateSubtreeRootsAction.swift +58 -0
- package/ios/ZCashLightClientKit/Block/Actions/ValidateServerAction.swift +60 -0
- package/ios/ZCashLightClientKit/Block/CompactBlockProcessor.swift +421 -937
- package/ios/ZCashLightClientKit/Block/Download/BlockDownloader.swift +31 -17
- package/ios/ZCashLightClientKit/Block/Download/BlockDownloaderService.swift +2 -2
- package/ios/ZCashLightClientKit/Block/Enhance/BlockEnhancer.swift +46 -15
- package/ios/ZCashLightClientKit/Block/FetchUnspentTxOutputs/UTXOFetcher.swift +4 -15
- package/ios/ZCashLightClientKit/Block/FilesystemStorage/FSCompactBlockRepository.swift +4 -4
- package/ios/ZCashLightClientKit/Block/Scan/BlockScanner.swift +10 -35
- package/ios/ZCashLightClientKit/Block/Utils/CompactBlockProgress.swift +24 -0
- package/ios/ZCashLightClientKit/Block/Utils/SyncControlData.swift +25 -0
- package/ios/ZCashLightClientKit/ClosureSynchronizer.swift +1 -2
- package/ios/ZCashLightClientKit/CombineSynchronizer.swift +2 -5
- package/ios/ZCashLightClientKit/Constants/ZcashSDK.swift +7 -25
- package/ios/ZCashLightClientKit/DAO/TransactionDao.swift +40 -42
- package/ios/ZCashLightClientKit/DAO/UnspentTransactionOutputDao.swift +13 -4
- package/ios/ZCashLightClientKit/Entity/AccountEntity.swift +9 -0
- package/ios/ZCashLightClientKit/Entity/TransactionEntity.swift +7 -10
- package/ios/ZCashLightClientKit/Error/Sourcery/generateErrorCode.sh +1 -1
- package/ios/ZCashLightClientKit/Error/ZcashError.swift +121 -12
- package/ios/ZCashLightClientKit/Error/ZcashErrorCode.swift +43 -5
- package/ios/ZCashLightClientKit/Error/ZcashErrorCodeDefinition.swift +72 -6
- package/ios/ZCashLightClientKit/Extensions/Bool+ToData.swift +15 -0
- package/ios/ZCashLightClientKit/Extensions/Data+ToOtherTypes.swift +18 -0
- package/ios/ZCashLightClientKit/Extensions/Int+ToData.swift +15 -0
- package/ios/ZCashLightClientKit/Initializer.swift +47 -26
- package/ios/ZCashLightClientKit/Metrics/SDKMetrics.swift +0 -12
- package/ios/ZCashLightClientKit/Model/Checkpoint.swift +12 -0
- package/ios/ZCashLightClientKit/Model/ScanProgress.swift +29 -0
- package/ios/ZCashLightClientKit/Model/ScanRange.swift +31 -0
- package/ios/ZCashLightClientKit/Modules/Service/GRPC/LightWalletGRPCService.swift +15 -0
- package/ios/ZCashLightClientKit/Modules/Service/GRPC/ProtoBuf/compact_formats.pb.swift +150 -46
- package/ios/ZCashLightClientKit/Modules/Service/GRPC/ProtoBuf/proto/compact_formats.proto +30 -16
- package/ios/ZCashLightClientKit/Modules/Service/GRPC/ProtoBuf/proto/service.proto +32 -6
- package/ios/ZCashLightClientKit/Modules/Service/GRPC/ProtoBuf/service.grpc.swift +259 -22
- package/ios/ZCashLightClientKit/Modules/Service/GRPC/ProtoBuf/service.pb.swift +193 -7
- package/ios/ZCashLightClientKit/Modules/Service/LightWalletService.swift +8 -0
- package/ios/ZCashLightClientKit/Providers/LatestBlocksDataProvider.swift +18 -28
- package/ios/ZCashLightClientKit/Repository/CompactBlockRepository.swift +1 -1
- package/ios/ZCashLightClientKit/Repository/TransactionRepository.swift +2 -6
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2092500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2095000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2097500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2102500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2105000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2107500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2112500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2115000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2117500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2122500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2125000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2127500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2132500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2135000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2137500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2142500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2145000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2147500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2152500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2155000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2157500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2162500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2165000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2167500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2172500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2175000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2177500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2182500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2185000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2187500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2192500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2195000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2197500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2202500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2205000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2207500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2212500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2215000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2217500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2222500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2225000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2227500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2230000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2232500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2235000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2237500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2240000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2242500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2245000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2247500.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/mainnet/2250000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/testnet/2350000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/testnet/2360000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/testnet/2370000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/testnet/2380000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/testnet/2390000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/testnet/2400000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/testnet/2410000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/testnet/2420000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/testnet/2430000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/testnet/2440000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/testnet/2450000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/testnet/2460000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/testnet/2470000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/testnet/2480000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/testnet/2490000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/testnet/2500000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/testnet/2510000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/testnet/2520000.json +8 -0
- package/ios/ZCashLightClientKit/Resources/checkpoints/testnet/2530000.json +8 -0
- package/ios/ZCashLightClientKit/Rust/ZcashRustBackend.swift +293 -158
- package/ios/ZCashLightClientKit/Rust/ZcashRustBackendWelding.swift +58 -64
- package/ios/ZCashLightClientKit/Rust/zcashlc.h +618 -512
- package/ios/ZCashLightClientKit/Synchronizer/ClosureSDKSynchronizer.swift +2 -8
- package/ios/ZCashLightClientKit/Synchronizer/CombineSDKSynchronizer.swift +3 -15
- package/ios/ZCashLightClientKit/Synchronizer/Dependencies.swift +11 -30
- package/ios/ZCashLightClientKit/Synchronizer/SDKSynchronizer.swift +41 -50
- package/ios/ZCashLightClientKit/Synchronizer.swift +51 -65
- package/ios/ZCashLightClientKit/Transaction/TransactionEncoder.swift +2 -2
- package/ios/ZCashLightClientKit/Transaction/WalletTransactionEncoder.swift +7 -7
- package/ios/ZCashLightClientKit/Utils/OSLogger.swift +3 -3
- package/ios/ZCashLightClientKit/Utils/ZcashFileManager.swift +16 -0
- package/ios/libzcashlc.xcframework/Info.plist +9 -5
- package/ios/libzcashlc.xcframework/ios-arm64/libzcashlc.a +0 -0
- package/ios/libzcashlc.xcframework/ios-arm64_x86_64-simulator/libzcashlc.a +0 -0
- package/lib/rnzcash.rn.js +8 -30
- package/lib/rnzcash.rn.js.map +1 -1
- package/lib/src/react-native.d.ts +3 -5
- package/lib/src/types.d.ts +19 -14
- package/package.json +1 -1
- package/src/react-native.ts +13 -21
- package/src/types.ts +26 -23
- package/ios/ZCashLightClientKit/Block/Utils/InternalSyncProgress.swift +0 -200
- package/ios/ZCashLightClientKit/Block/Validate/BlockValidator.swift +0 -51
- package/ios/ZCashLightClientKit/DAO/BlockDao.swift +0 -112
- package/ios/ZCashLightClientKit/Entity/BlockProgress.swift +0 -24
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
//
|
|
2
|
+
// EnhanceAction.swift
|
|
3
|
+
//
|
|
4
|
+
//
|
|
5
|
+
// Created by Michal Fousek on 05.05.2023.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import Foundation
|
|
9
|
+
|
|
10
|
+
final class EnhanceAction {
|
|
11
|
+
let blockEnhancer: BlockEnhancer
|
|
12
|
+
let configProvider: CompactBlockProcessor.ConfigProvider
|
|
13
|
+
let logger: Logger
|
|
14
|
+
|
|
15
|
+
init(container: DIContainer, configProvider: CompactBlockProcessor.ConfigProvider) {
|
|
16
|
+
blockEnhancer = container.resolve(BlockEnhancer.self)
|
|
17
|
+
self.configProvider = configProvider
|
|
18
|
+
logger = container.resolve(Logger.self)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func decideWhatToDoNext(context: ActionContext, lastScannedHeight: BlockHeight) async -> ActionContext {
|
|
22
|
+
guard await context.syncControlData.latestScannedHeight != nil else {
|
|
23
|
+
await context.update(state: .clearCache)
|
|
24
|
+
return context
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let latestBlockHeight = await context.syncControlData.latestBlockHeight
|
|
28
|
+
if lastScannedHeight >= latestBlockHeight {
|
|
29
|
+
await context.update(state: .clearCache)
|
|
30
|
+
} else {
|
|
31
|
+
await context.update(state: .updateChainTip)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return context
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
extension EnhanceAction: Action {
|
|
39
|
+
var removeBlocksCacheWhenFailed: Bool { false }
|
|
40
|
+
|
|
41
|
+
func run(with context: ActionContext, didUpdate: @escaping (CompactBlockProcessor.Event) async -> Void) async throws -> ActionContext {
|
|
42
|
+
// Use `BlockEnhancer` to enhance blocks.
|
|
43
|
+
// This action is executed on each downloaded and scanned batch (typically each 100 blocks). But we want to run enhancement each 1000 blocks.
|
|
44
|
+
|
|
45
|
+
// if latestScannedHeight >= context.scanRanges.scanRange.upperBound then everything is processed and sync process should continue to end.
|
|
46
|
+
// If latestScannedHeight < context.scanRanges.scanRange.upperBound then set state to `download` because there are blocks to
|
|
47
|
+
// download and scan.
|
|
48
|
+
|
|
49
|
+
let config = await configProvider.config
|
|
50
|
+
guard let lastScannedHeight = await context.lastScannedHeight else {
|
|
51
|
+
throw ZcashError.compactBlockProcessorLastScannedHeight
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
guard let firstUnenhancedHeight = await context.syncControlData.firstUnenhancedHeight else {
|
|
55
|
+
return await decideWhatToDoNext(context: context, lastScannedHeight: lastScannedHeight)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let latestBlockHeight = await context.syncControlData.latestBlockHeight
|
|
59
|
+
let lastEnhancedHeight: BlockHeight
|
|
60
|
+
if let lastEnhancedHeightInContext = await context.lastEnhancedHeight {
|
|
61
|
+
lastEnhancedHeight = lastEnhancedHeightInContext
|
|
62
|
+
} else {
|
|
63
|
+
lastEnhancedHeight = -1
|
|
64
|
+
}
|
|
65
|
+
let enhanceRangeStart = max(firstUnenhancedHeight, lastEnhancedHeight + 1)
|
|
66
|
+
let enhanceRangeEnd = min(latestBlockHeight, lastScannedHeight)
|
|
67
|
+
|
|
68
|
+
// This may happen:
|
|
69
|
+
// For example whole enhance range is 0...2100 Without this force enhance is done for ranges: 0...1000, 1001...2000. And that's it.
|
|
70
|
+
// Last 100 blocks isn't enhanced.
|
|
71
|
+
//
|
|
72
|
+
// This force makes sure that all the blocks are enhanced even when last enhance happened < 1000 blocks ago.
|
|
73
|
+
let forceEnhance = enhanceRangeEnd == latestBlockHeight && enhanceRangeEnd - enhanceRangeStart <= config.enhanceBatchSize
|
|
74
|
+
|
|
75
|
+
if enhanceRangeStart <= enhanceRangeEnd && (forceEnhance || (lastScannedHeight - lastEnhancedHeight >= config.enhanceBatchSize)) {
|
|
76
|
+
let enhanceRange = enhanceRangeStart...enhanceRangeEnd
|
|
77
|
+
let transactions = try await blockEnhancer.enhance(
|
|
78
|
+
at: enhanceRange,
|
|
79
|
+
didEnhance: { progress in
|
|
80
|
+
if let foundTx = progress.lastFoundTransaction, progress.newlyMined {
|
|
81
|
+
await didUpdate(.minedTransaction(foundTx))
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
await context.update(lastEnhancedHeight: enhanceRange.upperBound)
|
|
87
|
+
|
|
88
|
+
if let transactions {
|
|
89
|
+
await didUpdate(.foundTransactions(transactions, enhanceRange))
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return await decideWhatToDoNext(context: context, lastScannedHeight: lastScannedHeight)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func stop() async { }
|
|
97
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//
|
|
2
|
+
// FetchUTXOsAction.swift
|
|
3
|
+
//
|
|
4
|
+
//
|
|
5
|
+
// Created by Michal Fousek on 05.05.2023.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import Foundation
|
|
9
|
+
|
|
10
|
+
final class FetchUTXOsAction {
|
|
11
|
+
let utxoFetcher: UTXOFetcher
|
|
12
|
+
let logger: Logger
|
|
13
|
+
|
|
14
|
+
init(container: DIContainer) {
|
|
15
|
+
utxoFetcher = container.resolve(UTXOFetcher.self)
|
|
16
|
+
logger = container.resolve(Logger.self)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
extension FetchUTXOsAction: Action {
|
|
21
|
+
var removeBlocksCacheWhenFailed: Bool { false }
|
|
22
|
+
|
|
23
|
+
func run(with context: ActionContext, didUpdate: @escaping (CompactBlockProcessor.Event) async -> Void) async throws -> ActionContext {
|
|
24
|
+
logger.debug("Fetching UTXOs")
|
|
25
|
+
let result = try await utxoFetcher.fetch() { _ in }
|
|
26
|
+
await didUpdate(.storedUTXOs(result))
|
|
27
|
+
|
|
28
|
+
await context.update(state: .handleSaplingParams)
|
|
29
|
+
return context
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func stop() async { }
|
|
33
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
//
|
|
2
|
+
// MigrateLegacyCacheDB.swift
|
|
3
|
+
//
|
|
4
|
+
//
|
|
5
|
+
// Created by Michal Fousek on 10.05.2023.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import Foundation
|
|
9
|
+
|
|
10
|
+
final class MigrateLegacyCacheDBAction {
|
|
11
|
+
private let configProvider: CompactBlockProcessor.ConfigProvider
|
|
12
|
+
private let storage: CompactBlockRepository
|
|
13
|
+
private let transactionRepository: TransactionRepository
|
|
14
|
+
private let fileManager: ZcashFileManager
|
|
15
|
+
|
|
16
|
+
init(container: DIContainer, configProvider: CompactBlockProcessor.ConfigProvider) {
|
|
17
|
+
self.configProvider = configProvider
|
|
18
|
+
storage = container.resolve(CompactBlockRepository.self)
|
|
19
|
+
transactionRepository = container.resolve(TransactionRepository.self)
|
|
20
|
+
fileManager = container.resolve(ZcashFileManager.self)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private func updateState(_ context: ActionContext) async -> ActionContext {
|
|
24
|
+
await context.update(state: .validateServer)
|
|
25
|
+
return context
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
extension MigrateLegacyCacheDBAction: Action {
|
|
30
|
+
var removeBlocksCacheWhenFailed: Bool { false }
|
|
31
|
+
|
|
32
|
+
func run(with context: ActionContext, didUpdate: @escaping (CompactBlockProcessor.Event) async -> Void) async throws -> ActionContext {
|
|
33
|
+
let config = await configProvider.config
|
|
34
|
+
guard let legacyCacheDbURL = config.cacheDbURL else {
|
|
35
|
+
return await updateState(context)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
guard legacyCacheDbURL != config.fsBlockCacheRoot else {
|
|
39
|
+
throw ZcashError.compactBlockProcessorCacheDbMigrationFsCacheMigrationFailedSameURL
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Instance with alias `default` is same as instance before the Alias was introduced. So it makes sense that only this instance handles
|
|
43
|
+
// legacy cache DB. Any instance with different than `default` alias was created after the Alias was introduced and at this point legacy
|
|
44
|
+
// cache DB doesn't exist anymore. So there is nothing to migrate for instances with not default Alias.
|
|
45
|
+
guard config.alias == .default else {
|
|
46
|
+
return await updateState(context)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// if the URL provided is not readable, it means that the client has a reference
|
|
50
|
+
// to the cacheDb file but it has been deleted in a prior sync cycle. there's
|
|
51
|
+
// nothing to do here.
|
|
52
|
+
guard fileManager.isReadableFile(atPath: legacyCacheDbURL.path) else {
|
|
53
|
+
return await updateState(context)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
do {
|
|
57
|
+
// if there's a readable file at the provided URL, delete it.
|
|
58
|
+
try fileManager.removeItem(at: legacyCacheDbURL)
|
|
59
|
+
} catch {
|
|
60
|
+
throw ZcashError.compactBlockProcessorCacheDbMigrationFailedToDeleteLegacyDb(error)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// create the storage
|
|
64
|
+
try await self.storage.create()
|
|
65
|
+
|
|
66
|
+
return await updateState(context)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func stop() { }
|
|
70
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
//
|
|
2
|
+
// ProcessSuggestedScanRangesAction.swift
|
|
3
|
+
//
|
|
4
|
+
//
|
|
5
|
+
// Created by Lukáš Korba on 02.08.2023.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import Foundation
|
|
9
|
+
|
|
10
|
+
final class ProcessSuggestedScanRangesAction {
|
|
11
|
+
let rustBackend: ZcashRustBackendWelding
|
|
12
|
+
let service: LightWalletService
|
|
13
|
+
let logger: Logger
|
|
14
|
+
|
|
15
|
+
init(container: DIContainer) {
|
|
16
|
+
service = container.resolve(LightWalletService.self)
|
|
17
|
+
rustBackend = container.resolve(ZcashRustBackendWelding.self)
|
|
18
|
+
logger = container.resolve(Logger.self)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
extension ProcessSuggestedScanRangesAction: Action {
|
|
23
|
+
var removeBlocksCacheWhenFailed: Bool { false }
|
|
24
|
+
|
|
25
|
+
func run(with context: ActionContext, didUpdate: @escaping (CompactBlockProcessor.Event) async -> Void) async throws -> ActionContext {
|
|
26
|
+
logger.info("Getting the suggested scan ranges from the wallet database.")
|
|
27
|
+
let scanRanges = try await rustBackend.suggestScanRanges()
|
|
28
|
+
|
|
29
|
+
if let firstRange = scanRanges.first {
|
|
30
|
+
let rangeStartExclusive = firstRange.range.lowerBound - 1
|
|
31
|
+
let rangeEndInclusive = firstRange.range.upperBound - 1
|
|
32
|
+
|
|
33
|
+
let syncControlData = SyncControlData(
|
|
34
|
+
latestBlockHeight: rangeEndInclusive,
|
|
35
|
+
latestScannedHeight: rangeStartExclusive,
|
|
36
|
+
firstUnenhancedHeight: rangeStartExclusive + 1
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
logger.debug("""
|
|
40
|
+
Init numbers:
|
|
41
|
+
latestBlockHeight [BC]: \(rangeEndInclusive)
|
|
42
|
+
latestScannedHeight [DB]: \(rangeStartExclusive)
|
|
43
|
+
firstUnenhancedHeight [DB]: \(rangeStartExclusive + 1)
|
|
44
|
+
""")
|
|
45
|
+
|
|
46
|
+
await context.update(lastScannedHeight: rangeStartExclusive)
|
|
47
|
+
await context.update(lastDownloadedHeight: rangeStartExclusive)
|
|
48
|
+
await context.update(syncControlData: syncControlData)
|
|
49
|
+
|
|
50
|
+
await context.update(state: .download)
|
|
51
|
+
} else {
|
|
52
|
+
await context.update(state: .finished)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return context
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func stop() async { }
|
|
59
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
//
|
|
2
|
+
// RewindAction.swift
|
|
3
|
+
//
|
|
4
|
+
//
|
|
5
|
+
// Created by Lukáš Korba on 09.08.2023.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import Foundation
|
|
9
|
+
|
|
10
|
+
final class RewindAction {
|
|
11
|
+
let downloader: BlockDownloader
|
|
12
|
+
let rustBackend: ZcashRustBackendWelding
|
|
13
|
+
let downloaderService: BlockDownloaderService
|
|
14
|
+
let logger: Logger
|
|
15
|
+
|
|
16
|
+
init(container: DIContainer) {
|
|
17
|
+
downloader = container.resolve(BlockDownloader.self)
|
|
18
|
+
rustBackend = container.resolve(ZcashRustBackendWelding.self)
|
|
19
|
+
downloaderService = container.resolve(BlockDownloaderService.self)
|
|
20
|
+
logger = container.resolve(Logger.self)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private func update(context: ActionContext) async -> ActionContext {
|
|
24
|
+
await context.update(state: .processSuggestedScanRanges)
|
|
25
|
+
return context
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
extension RewindAction: Action {
|
|
30
|
+
var removeBlocksCacheWhenFailed: Bool { false }
|
|
31
|
+
|
|
32
|
+
func run(with context: ActionContext, didUpdate: @escaping (CompactBlockProcessor.Event) async -> Void) async throws -> ActionContext {
|
|
33
|
+
guard let rewindHeight = await context.requestedRewindHeight else {
|
|
34
|
+
return await update(context: context)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
logger.debug("Executing rewind.")
|
|
38
|
+
await downloader.rewind(latestDownloadedBlockHeight: rewindHeight)
|
|
39
|
+
try await rustBackend.rewindToHeight(height: Int32(rewindHeight))
|
|
40
|
+
|
|
41
|
+
// clear cache
|
|
42
|
+
try await downloaderService.rewind(to: rewindHeight)
|
|
43
|
+
|
|
44
|
+
return await update(context: context)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func stop() async { }
|
|
48
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//
|
|
2
|
+
// SaplingParamsAction.swift
|
|
3
|
+
//
|
|
4
|
+
//
|
|
5
|
+
// Created by Michal Fousek on 05.05.2023.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import Foundation
|
|
9
|
+
|
|
10
|
+
final class SaplingParamsAction {
|
|
11
|
+
let saplingParametersHandler: SaplingParametersHandler
|
|
12
|
+
let logger: Logger
|
|
13
|
+
|
|
14
|
+
init(container: DIContainer) {
|
|
15
|
+
saplingParametersHandler = container.resolve(SaplingParametersHandler.self)
|
|
16
|
+
logger = container.resolve(Logger.self)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
extension SaplingParamsAction: Action {
|
|
21
|
+
var removeBlocksCacheWhenFailed: Bool { false }
|
|
22
|
+
|
|
23
|
+
func run(with context: ActionContext, didUpdate: @escaping (CompactBlockProcessor.Event) async -> Void) async throws -> ActionContext {
|
|
24
|
+
logger.debug("Fetching sapling parameters")
|
|
25
|
+
try await saplingParametersHandler.handleIfNeeded()
|
|
26
|
+
|
|
27
|
+
await context.update(state: .updateSubtreeRoots)
|
|
28
|
+
|
|
29
|
+
return context
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func stop() async { }
|
|
33
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
//
|
|
2
|
+
// ScanAction.swift
|
|
3
|
+
//
|
|
4
|
+
//
|
|
5
|
+
// Created by Michal Fousek on 05.05.2023.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import Foundation
|
|
9
|
+
|
|
10
|
+
final class ScanAction {
|
|
11
|
+
let configProvider: CompactBlockProcessor.ConfigProvider
|
|
12
|
+
let blockScanner: BlockScanner
|
|
13
|
+
let rustBackend: ZcashRustBackendWelding
|
|
14
|
+
let latestBlocksDataProvider: LatestBlocksDataProvider
|
|
15
|
+
let logger: Logger
|
|
16
|
+
|
|
17
|
+
init(container: DIContainer, configProvider: CompactBlockProcessor.ConfigProvider) {
|
|
18
|
+
self.configProvider = configProvider
|
|
19
|
+
blockScanner = container.resolve(BlockScanner.self)
|
|
20
|
+
rustBackend = container.resolve(ZcashRustBackendWelding.self)
|
|
21
|
+
latestBlocksDataProvider = container.resolve(LatestBlocksDataProvider.self)
|
|
22
|
+
logger = container.resolve(Logger.self)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private func update(context: ActionContext) async -> ActionContext {
|
|
26
|
+
await context.update(state: .clearAlreadyScannedBlocks)
|
|
27
|
+
return context
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
extension ScanAction: Action {
|
|
32
|
+
var removeBlocksCacheWhenFailed: Bool { true }
|
|
33
|
+
|
|
34
|
+
func run(with context: ActionContext, didUpdate: @escaping (CompactBlockProcessor.Event) async -> Void) async throws -> ActionContext {
|
|
35
|
+
guard let lastScannedHeight = await context.lastScannedHeight else {
|
|
36
|
+
return await update(context: context)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let config = await configProvider.config
|
|
40
|
+
let latestBlockHeight = await context.syncControlData.latestBlockHeight
|
|
41
|
+
// This action is executed for each batch (batch size is 100 blocks by default) until all the blocks in whole `scanRange` are scanned.
|
|
42
|
+
// So the right range for this batch must be computed.
|
|
43
|
+
let batchRangeStart = lastScannedHeight
|
|
44
|
+
let batchRangeEnd = min(latestBlockHeight, batchRangeStart + config.batchSize)
|
|
45
|
+
|
|
46
|
+
guard batchRangeStart <= batchRangeEnd else {
|
|
47
|
+
return await update(context: context)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let batchRange = batchRangeStart...batchRangeEnd
|
|
51
|
+
|
|
52
|
+
logger.debug("Starting scan blocks with range: \(batchRange.lowerBound)...\(batchRange.upperBound)")
|
|
53
|
+
|
|
54
|
+
do {
|
|
55
|
+
try await blockScanner.scanBlocks(at: batchRange) { [weak self] lastScannedHeight, increment in
|
|
56
|
+
let processedHeight = await context.processedHeight
|
|
57
|
+
let incrementedprocessedHeight = processedHeight + BlockHeight(increment)
|
|
58
|
+
await context.update(processedHeight: incrementedprocessedHeight)
|
|
59
|
+
await self?.latestBlocksDataProvider.updateScannedData()
|
|
60
|
+
|
|
61
|
+
// report scan progress only if it's available
|
|
62
|
+
if let scanProgress = try? await self?.rustBackend.getScanProgress() {
|
|
63
|
+
let progress = try scanProgress.progress()
|
|
64
|
+
self?.logger.debug("progress: \(progress)")
|
|
65
|
+
await didUpdate(.syncProgress(progress))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ScanAction is controlled locally so it must report back the updated scanned height
|
|
69
|
+
await context.update(lastScannedHeight: lastScannedHeight)
|
|
70
|
+
}
|
|
71
|
+
} catch ZcashError.rustScanBlocks(let errorMsg) {
|
|
72
|
+
if isContinuityError(errorMsg) {
|
|
73
|
+
await context.update(requestedRewindHeight: batchRange.lowerBound - 10)
|
|
74
|
+
await context.update(state: .rewind)
|
|
75
|
+
return context
|
|
76
|
+
} else {
|
|
77
|
+
throw ZcashError.rustScanBlocks(errorMsg)
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
throw error
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return await update(context: context)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func stop() async { }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private extension ScanAction {
|
|
90
|
+
func isContinuityError(_ errorMsg: String) -> Bool {
|
|
91
|
+
errorMsg.contains("The parent hash of proposed block does not correspond to the block hash at height")
|
|
92
|
+
|| errorMsg.contains("Block height discontinuity at height")
|
|
93
|
+
|| errorMsg.contains("note commitment tree size provided by a compact block did not match the expected size at height")
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
//
|
|
2
|
+
// UpdateChainTipAction.swift
|
|
3
|
+
//
|
|
4
|
+
//
|
|
5
|
+
// Created by Lukáš Korba on 01.08.2023.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import Foundation
|
|
9
|
+
|
|
10
|
+
final class UpdateChainTipAction {
|
|
11
|
+
let rustBackend: ZcashRustBackendWelding
|
|
12
|
+
let downloader: BlockDownloader
|
|
13
|
+
let service: LightWalletService
|
|
14
|
+
let latestBlocksDataProvider: LatestBlocksDataProvider
|
|
15
|
+
let logger: Logger
|
|
16
|
+
|
|
17
|
+
init(container: DIContainer) {
|
|
18
|
+
service = container.resolve(LightWalletService.self)
|
|
19
|
+
downloader = container.resolve(BlockDownloader.self)
|
|
20
|
+
rustBackend = container.resolve(ZcashRustBackendWelding.self)
|
|
21
|
+
latestBlocksDataProvider = container.resolve(LatestBlocksDataProvider.self)
|
|
22
|
+
logger = container.resolve(Logger.self)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func updateChainTip(_ context: ActionContext, time: TimeInterval) async throws {
|
|
26
|
+
let latestBlockHeight = try await service.latestBlockHeight()
|
|
27
|
+
|
|
28
|
+
logger.info("Latest block height is \(latestBlockHeight)")
|
|
29
|
+
try await rustBackend.updateChainTip(height: Int32(latestBlockHeight))
|
|
30
|
+
await context.update(lastChainTipUpdateTime: time)
|
|
31
|
+
await latestBlocksDataProvider.update(latestBlockHeight)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
extension UpdateChainTipAction: Action {
|
|
36
|
+
var removeBlocksCacheWhenFailed: Bool { false }
|
|
37
|
+
|
|
38
|
+
func run(with context: ActionContext, didUpdate: @escaping (CompactBlockProcessor.Event) async -> Void) async throws -> ActionContext {
|
|
39
|
+
let lastChainTipUpdateTime = await context.lastChainTipUpdateTime
|
|
40
|
+
let now = Date().timeIntervalSince1970
|
|
41
|
+
|
|
42
|
+
// Update chain tip can be called from different contexts
|
|
43
|
+
if await context.prevState == .updateSubtreeRoots || now - lastChainTipUpdateTime > 600 {
|
|
44
|
+
await downloader.stopDownload()
|
|
45
|
+
try await updateChainTip(context, time: now)
|
|
46
|
+
await context.update(state: .clearCache)
|
|
47
|
+
} else if await context.prevState == .enhance {
|
|
48
|
+
await context.update(state: .download)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return context
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func stop() async { }
|
|
55
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
//
|
|
2
|
+
// UpdateSubtreeRootsAction.swift
|
|
3
|
+
//
|
|
4
|
+
//
|
|
5
|
+
// Created by Lukas Korba on 01.08.2023.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import Foundation
|
|
9
|
+
|
|
10
|
+
final class UpdateSubtreeRootsAction {
|
|
11
|
+
let configProvider: CompactBlockProcessor.ConfigProvider
|
|
12
|
+
let rustBackend: ZcashRustBackendWelding
|
|
13
|
+
let service: LightWalletService
|
|
14
|
+
let logger: Logger
|
|
15
|
+
|
|
16
|
+
init(container: DIContainer, configProvider: CompactBlockProcessor.ConfigProvider) {
|
|
17
|
+
self.configProvider = configProvider
|
|
18
|
+
service = container.resolve(LightWalletService.self)
|
|
19
|
+
rustBackend = container.resolve(ZcashRustBackendWelding.self)
|
|
20
|
+
logger = container.resolve(Logger.self)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
extension UpdateSubtreeRootsAction: Action {
|
|
25
|
+
var removeBlocksCacheWhenFailed: Bool { false }
|
|
26
|
+
|
|
27
|
+
func run(with context: ActionContext, didUpdate: @escaping (CompactBlockProcessor.Event) async -> Void) async throws -> ActionContext {
|
|
28
|
+
var request = GetSubtreeRootsArg()
|
|
29
|
+
request.shieldedProtocol = .sapling
|
|
30
|
+
|
|
31
|
+
logger.info("Attempt to get subtree roots, this may fail because lightwalletd may not support Spend before Sync.")
|
|
32
|
+
let stream = service.getSubtreeRoots(request)
|
|
33
|
+
|
|
34
|
+
var roots: [SubtreeRoot] = []
|
|
35
|
+
|
|
36
|
+
do {
|
|
37
|
+
for try await subtreeRoot in stream {
|
|
38
|
+
roots.append(subtreeRoot)
|
|
39
|
+
}
|
|
40
|
+
} catch ZcashError.serviceSubtreeRootsStreamFailed(LightWalletServiceError.timeOut) {
|
|
41
|
+
throw ZcashError.serviceSubtreeRootsStreamFailed(LightWalletServiceError.timeOut)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
logger.info("Sapling tree has \(roots.count) subtrees")
|
|
45
|
+
do {
|
|
46
|
+
try await rustBackend.putSaplingSubtreeRoots(startIndex: UInt64(request.startIndex), roots: roots)
|
|
47
|
+
|
|
48
|
+
await context.update(state: .updateChainTip)
|
|
49
|
+
} catch {
|
|
50
|
+
logger.debug("putSaplingSubtreeRoots failed with error \(error.localizedDescription)")
|
|
51
|
+
throw ZcashError.compactBlockProcessorPutSaplingSubtreeRoots(error)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return context
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func stop() async { }
|
|
58
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
//
|
|
2
|
+
// ValidateServerAction.swift
|
|
3
|
+
//
|
|
4
|
+
//
|
|
5
|
+
// Created by Michal Fousek on 05.05.2023.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import Foundation
|
|
9
|
+
|
|
10
|
+
final class ValidateServerAction {
|
|
11
|
+
let configProvider: CompactBlockProcessor.ConfigProvider
|
|
12
|
+
let rustBackend: ZcashRustBackendWelding
|
|
13
|
+
let service: LightWalletService
|
|
14
|
+
|
|
15
|
+
init(container: DIContainer, configProvider: CompactBlockProcessor.ConfigProvider) {
|
|
16
|
+
self.configProvider = configProvider
|
|
17
|
+
rustBackend = container.resolve(ZcashRustBackendWelding.self)
|
|
18
|
+
service = container.resolve(LightWalletService.self)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
extension ValidateServerAction: Action {
|
|
23
|
+
var removeBlocksCacheWhenFailed: Bool { false }
|
|
24
|
+
|
|
25
|
+
func run(with context: ActionContext, didUpdate: @escaping (CompactBlockProcessor.Event) async -> Void) async throws -> ActionContext {
|
|
26
|
+
let config = await configProvider.config
|
|
27
|
+
let info = try await service.getInfo()
|
|
28
|
+
let localNetwork = config.network
|
|
29
|
+
let saplingActivation = config.saplingActivation
|
|
30
|
+
|
|
31
|
+
// check network types
|
|
32
|
+
guard let remoteNetworkType = NetworkType.forChainName(info.chainName) else {
|
|
33
|
+
throw ZcashError.compactBlockProcessorChainName(info.chainName)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
guard remoteNetworkType == localNetwork.networkType else {
|
|
37
|
+
throw ZcashError.compactBlockProcessorNetworkMismatch(localNetwork.networkType, remoteNetworkType)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
guard saplingActivation == info.saplingActivationHeight else {
|
|
41
|
+
throw ZcashError.compactBlockProcessorSaplingActivationMismatch(saplingActivation, BlockHeight(info.saplingActivationHeight))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// check branch id
|
|
45
|
+
let localBranch = try rustBackend.consensusBranchIdFor(height: Int32(info.blockHeight))
|
|
46
|
+
|
|
47
|
+
guard let remoteBranchID = ConsensusBranchID.fromString(info.consensusBranchID) else {
|
|
48
|
+
throw ZcashError.compactBlockProcessorConsensusBranchID
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
guard remoteBranchID == localBranch else {
|
|
52
|
+
throw ZcashError.compactBlockProcessorWrongConsensusBranchId(localBranch, remoteBranchID)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await context.update(state: .fetchUTXO)
|
|
56
|
+
return context
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func stop() async { }
|
|
60
|
+
}
|