Transaction submission + tests (#34)

* transaction manager

* Transaction Manager Tests

* pending transactions DAO + Scaffold tests

* PendingTransactionsDao + tests

* Persistent Transaction Manager
This commit is contained in:
Francisco Gindre 2019-12-03 12:19:44 -03:00 committed by GitHub
parent c772934d3d
commit d8affaebd3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 873 additions and 38 deletions

View File

@ -22,4 +22,6 @@ enum StorageError: Error {
case openFailed
case closeFailed
case operationFailed
case updateFailed
case malformedEntity(fields: [String]?)
}

View File

@ -6,37 +6,179 @@
//
import Foundation
import SQLite
struct PendingTransaction: PendingTransactionEntity, Decodable, Encodable {
enum CodingKeys: String, CodingKey {
case toAddress = "to_address"
case accountIndex = "account_index"
case minedHeight = "mined_height"
case expiryHeight = "expiry_height"
case cancelled
case encodeAttempts = "encode_attempts"
case submitAttempts = "submit_attempts"
case errorMessage = "error_message"
case errorCode = "error_code"
case createTime = "create_time"
case raw
case id
case value
case memo
case rawTransactionId = "txid"
}
var toAddress: String
var accountIndex: Int
var minedHeight: BlockHeight
var expiryHeight: BlockHeight
var cancelled: Int
var encodeAttempts: Int
var submitAttempts: Int
var errorMessage: String?
var errorCode: Int?
var createTime: TimeInterval
var raw: Data?
var id: Int?
var value: Int
var memo: Data?
var rawTransactionId: Data?
func isSameTransactionId<T>(other: T) -> Bool where T : RawIdentifiable {
self.rawTransactionId == other.rawTransactionId
}
static func from(entity: PendingTransactionEntity) -> PendingTransaction {
PendingTransaction(toAddress: entity.toAddress,
accountIndex: entity.accountIndex,
minedHeight: entity.minedHeight,
expiryHeight: entity.expiryHeight,
cancelled: entity.cancelled,
encodeAttempts: entity.encodeAttempts,
submitAttempts: entity.submitAttempts,
errorMessage: entity.errorMessage,
errorCode: entity.errorCode,
createTime: entity.createTime,
raw: entity.raw,
id: entity.id,
value: entity.value,
memo: entity.memo,
rawTransactionId: entity.raw)
}
}
extension PendingTransaction {
// TODO: Handle Memo
init(value: Int, toAddress: String, memo: String?, account index: Int) {
self = PendingTransaction(toAddress: toAddress, accountIndex: index, minedHeight: -1, expiryHeight: -1, cancelled: 0, encodeAttempts: 0, submitAttempts: 0, errorMessage: nil, errorCode: nil, createTime: Date().timeIntervalSince1970, raw: nil, id: nil, value: Int(value), memo: nil, rawTransactionId: nil)
}
}
class PendingTransactionSQLDAO: PendingTransactionRepository {
let table = Table("pending_transactions")
struct TableColumns {
static var toAddress = Expression<String>("to_address")
static var accountIndex = Expression<Int>("account_index")
static var minedHeight = Expression<Int?>("mined_height")
static var expiryHeight = Expression<Int?>("expiry_height")
static var cancelled = Expression<Int?>("cancelled")
static var encodeAttempts = Expression<Int?>("encode_attempts")
static var errorMessage = Expression<String?>("error_message")
static var submitAttempts = Expression<Int?>("submit_attempts")
static var errorCode = Expression<Int?>("error_code")
static var createTime = Expression<TimeInterval?>("create_time")
static var raw = Expression<Blob?>("raw")
static var id = Expression<Int>("id")
static var value = Expression<Int>("value")
static var memo = Expression<Blob?>("memo")
static var rawTransactionId = Expression<Blob?>("txid")
}
var dbProvider: ConnectionProvider
init(dbProvider: ConnectionProvider) {
self.dbProvider = dbProvider
}
func create(_ transaction: PendingTransactionEntity) throws -> Int64 {
-1
func createrTableIfNeeded() throws {
let statement = table.create(ifNotExists: true) { t in
t.column(TableColumns.id, primaryKey: .autoincrement)
t.column(TableColumns.toAddress)
t.column(TableColumns.accountIndex)
t.column(TableColumns.minedHeight)
t.column(TableColumns.expiryHeight)
t.column(TableColumns.cancelled)
t.column(TableColumns.encodeAttempts, defaultValue: 0)
t.column(TableColumns.errorMessage)
t.column(TableColumns.errorCode)
t.column(TableColumns.submitAttempts, defaultValue: 0)
t.column(TableColumns.createTime)
t.column(TableColumns.rawTransactionId)
t.column(TableColumns.value)
t.column(TableColumns.raw)
t.column(TableColumns.memo)
}
try dbProvider.connection().run(statement)
}
func create(_ transaction: PendingTransactionEntity) throws -> Int {
let tx = transaction as? PendingTransaction ?? PendingTransaction.from(entity: transaction)
return try Int(dbProvider.connection().run(table.insert(tx)))
}
func update(_ transaction: PendingTransactionEntity) throws {
let tx = transaction as? PendingTransaction ?? PendingTransaction.from(entity: transaction)
guard let id = tx.id else {
throw StorageError.malformedEntity(fields: ["id"])
}
try dbProvider.connection().run(table.filter(TableColumns.id == id).update(tx))
}
func delete(_ transaction: PendingTransactionEntity) throws {
guard let id = transaction.id else {
throw StorageError.malformedEntity(fields: ["id"])
}
do {
try dbProvider.connection().run(table.filter(TableColumns.id == id).delete())
} catch {
throw StorageError.updateFailed
}
}
func cancel(_ transaction: PendingTransactionEntity) throws {
var tx = transaction as? PendingTransaction ?? PendingTransaction.from(entity: transaction)
tx.cancelled = 1
guard let id = tx.id else {
throw StorageError.malformedEntity(fields: ["id"])
}
try dbProvider.connection().run(table.filter(TableColumns.id == id).update(tx))
}
func find(by id: Int64) throws -> PendingTransactionEntity? {
nil
func find(by id: Int) throws -> PendingTransactionEntity? {
guard let row = try dbProvider.connection().pluck(table.filter(TableColumns.id == id).limit(1)) else {
return nil
}
do {
let tx: PendingTransaction = try row.decode()
return tx
} catch {
throw StorageError.operationFailed
}
}
func getAll() throws -> [PendingTransactionEntity] {
[]
let allTxs: [PendingTransaction] = try dbProvider.connection().prepare(table).map({ row in
try row.decode()
})
return allTxs
}
}

View File

@ -76,7 +76,7 @@ struct TransactionBuilder {
blockTimeInSeconds: TimeInterval(integerLiteral: blockTimeInSeconds),
transactionIndex: Int(transactionIndex),
raw: raw,
id: UInt(id),
id: Int(id),
value: Int(value),
memo: memo,
rawTransactionId: transactionId)
@ -110,7 +110,7 @@ struct TransactionBuilder {
blockTimeInSeconds: TimeInterval(integerLiteral: blockTimeInSeconds),
transactionIndex: Int(transactionIndex),
raw: nil,
id: UInt(id),
id: Int(id),
value: Int(value),
memo: memo,
rawTransactionId: transactionId)

View File

@ -36,7 +36,7 @@ struct ConfirmedTransaction: ConfirmedTransactionEntity {
var blockTimeInSeconds: TimeInterval
var transactionIndex: Int
var raw: Data?
var id: UInt
var id: Int?
var value: Int
var memo: Data?
var rawTransactionId: Data?
@ -45,12 +45,12 @@ struct ConfirmedTransaction: ConfirmedTransactionEntity {
class TransactionSQLDAO: TransactionRepository {
struct TableStructure {
static var id = Expression<Int64>(Transaction.CodingKeys.id.rawValue)
static var id = Expression<Int>(Transaction.CodingKeys.id.rawValue)
static var transactionId = Expression<Blob>(Transaction.CodingKeys.transactionId.rawValue)
static var created = Expression<String?>(Transaction.CodingKeys.created.rawValue)
static var txIndex = Expression<String?>(Transaction.CodingKeys.transactionIndex.rawValue)
static var expiryHeight = Expression<Int64?>(Transaction.CodingKeys.expiryHeight.rawValue)
static var minedHeight = Expression<Int64?>(Transaction.CodingKeys.minedHeight.rawValue)
static var expiryHeight = Expression<Int?>(Transaction.CodingKeys.expiryHeight.rawValue)
static var minedHeight = Expression<Int?>(Transaction.CodingKeys.minedHeight.rawValue)
static var raw = Expression<Blob?>(Transaction.CodingKeys.raw.rawValue)
}
@ -70,8 +70,8 @@ class TransactionSQLDAO: TransactionRepository {
try dbProvider.connection().scalar(transactions.filter(TableStructure.minedHeight == nil).count)
}
func findBy(id: Int64) throws -> TransactionEntity? {
let query = transactions.filter(TableStructure.id == Int64(id)).limit(1)
func findBy(id: Int) throws -> TransactionEntity? {
let query = transactions.filter(TableStructure.id == id).limit(1)
let sequence = try dbProvider.connection().prepare(query)
let entity: Transaction? = try sequence.map({ try $0.decode() }).first
return entity

View File

@ -15,7 +15,7 @@ public protocol PendingTransactionEntity: SignedTransactionEntity, AbstractTrans
var cancelled: Int { get set }
var encodeAttempts: Int { get set }
var submitAttempts: Int { get set }
var errorMesssage: String? { get set }
var errorMessage: String? { get set }
var errorCode: Int? { get set }
var createTime: TimeInterval { get set }
@ -47,7 +47,7 @@ public extension PendingTransactionEntity {
}
var isFailedSubmit: Bool {
errorMesssage != nil || (errorCode != nil && (errorCode ?? 0) < 0)
errorMessage != nil || (errorCode != nil && (errorCode ?? 0) < 0)
}
var isFailure: Bool {
@ -72,6 +72,6 @@ public extension PendingTransactionEntity {
}
var isSubmitSuccess: Bool {
submitAttempts > 0 && (errorCode != nil && (errorCode ?? -1) >= 0) && errorMesssage == nil
submitAttempts > 0 && (errorCode != nil && (errorCode ?? -1) >= 0) && errorMessage == nil
}
}

View File

@ -43,7 +43,7 @@ public extension TransactionEntity {
}
public protocol AbstractTransaction {
var id: UInt { get set }
var id: Int? { get set }
var value: Int { get set }
var memo: Data? { get set }
}

View File

@ -8,10 +8,10 @@
import Foundation
protocol PendingTransactionRepository {
func create(_ transaction: PendingTransactionEntity) throws -> Int64
func create(_ transaction: PendingTransactionEntity) throws -> Int
func update(_ transaction: PendingTransactionEntity) throws
func delete(_ transaction: PendingTransactionEntity) throws
func cancel(_ transaction: PendingTransactionEntity) throws
func find(by id: Int64) throws -> PendingTransactionEntity?
func find(by id: Int) throws -> PendingTransactionEntity?
func getAll() throws -> [PendingTransactionEntity]
}

View File

@ -14,7 +14,7 @@ enum TransactionRepositoryError: Error {
protocol TransactionRepository {
func countAll() throws -> Int
func countUnmined() throws -> Int
func findBy(id: Int64) throws -> TransactionEntity?
func findBy(id: Int) throws -> TransactionEntity?
func findBy(rawId: Data) throws -> TransactionEntity?
func findAllSentTransactions(limit: Int) throws -> [ConfirmedTransactionEntity]?
func findAllReceivedTransactions(limit: Int) throws -> [ConfirmedTransactionEntity]?

View File

@ -51,6 +51,24 @@ public class LightWalletGRPCService {
}
extension LightWalletGRPCService: LightWalletService {
public func submit(spendTransaction: Data, result: @escaping (Result<LightWalletServiceResponse, LightWalletServiceError>) -> Void) {
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self = self else { return }
do {
let response = try self.compactTxStreamer.sendTransaction(RawTransaction(serializedData: spendTransaction))
result(.success(response))
} catch {
result(.failure(LightWalletServiceError.genericError(error: error)))
}
}
}
public func submit(spendTransaction: Data) throws -> LightWalletServiceResponse {
try compactTxStreamer.sendTransaction(RawTransaction(serializedData: spendTransaction))
}
public func blockRange(_ range: CompactBlockRange) throws -> [ZcashCompactBlock] {
var blocks = [ZcashCompactBlock]()
@ -142,3 +160,4 @@ extension LightWalletGRPCService: LightWalletService {
}
}

View File

@ -8,11 +8,13 @@
import Foundation
import SwiftGRPC
import SwiftProtobuf
public enum LightWalletServiceError: Error {
case generalError
case failed(statusCode: StatusCode)
case invalidBlock
case sentFailed(sendResponse: LightWalletServiceResponse)
case genericError(error: Error)
}
extension LightWalletServiceError: Equatable {
@ -40,11 +42,28 @@ extension LightWalletServiceError: Equatable {
default:
return false
}
case .sentFailed(let sendResponse):
switch rhs {
case .sentFailed(let response):
return response.errorCode == sendResponse.errorCode
default:
return false
}
case .genericError(_):
return false
}
}
}
public protocol LightWalletServiceResponse {
var errorCode: Int32 { get }
var errorMessage: String { get }
var unknownFields: SwiftProtobuf.UnknownStorage { get }
}
extension SendResponse: LightWalletServiceResponse {}
public protocol LightWalletService {
/**
Return the latest block height known to the service.
@ -79,4 +98,16 @@ public protocol LightWalletService {
*/
func blockRange(_ range: CompactBlockRange) throws -> [ZcashCompactBlock]
/**
Submits a raw transaction over lightwalletd. Non-Blocking
*/
func submit(spendTransaction: Data, result: @escaping(Result<LightWalletServiceResponse,LightWalletServiceError>) -> Void)
/**
Submits a raw transaction over lightwalletd. Blocking
*/
func submit(spendTransaction: Data) throws -> LightWalletServiceResponse
}

View File

@ -0,0 +1,168 @@
//
// PendingTransactionsManager.swift
// ZcashLightClientKit
//
// Created by Francisco Gindre on 11/26/19.
//
import Foundation
enum TransactionManagerError: Error {
case couldNotCreateSpend(toAddress: String, account: Int, zatoshi: Int)
case encodingFailed(tx: PendingTransactionEntity)
case updateFailed(tx: PendingTransactionEntity)
case notPending(tx: PendingTransactionEntity)
case cancelled(tx: PendingTransactionEntity)
case internalInconsistency(tx: PendingTransactionEntity)
case submitFailed(tx: PendingTransactionEntity, errorCode: Int)
}
class PersistentTransactionManager: OutboundTransactionManager {
var repository: PendingTransactionRepository
var encoder: TransactionEncoder
var service: LightWalletService
var queue: DispatchQueue
init(encoder: TransactionEncoder, service: LightWalletService, repository: PendingTransactionRepository) {
self.repository = repository
self.encoder = encoder
self.service = service
self.queue = DispatchQueue.init(label: "PersistentTransactionManager.serial.queue")
}
func initSpend(zatoshi: Int, toAddress: String, memo: String?, from accountIndex: Int) throws -> PendingTransactionEntity {
guard let insertedTx = try repository.find(by: try repository.create(PendingTransaction(value: zatoshi, toAddress: toAddress, memo: memo, account: accountIndex))) else {
throw TransactionManagerError.couldNotCreateSpend(toAddress: toAddress, account: accountIndex, zatoshi: zatoshi)
}
print("pending transaction \(String(describing: insertedTx.id)) created")
return insertedTx
}
func encode(spendingKey: String, pendingTransaction: PendingTransactionEntity, result: @escaping (Result<PendingTransactionEntity, Error>) -> Void) {
// FIX: change to async when librustzcash is updated to v6
queue.sync { [weak self] in
guard let self = self else { return }
do {
let encodedTransaction = try self.encoder.createTransaction(spendingKey: spendingKey, zatoshi: pendingTransaction.value, to: pendingTransaction.toAddress, memo: pendingTransaction.memo?.asZcashTransactionMemo(), from: pendingTransaction.accountIndex)
var pending = pendingTransaction
pending.encodeAttempts = 1
pending.raw = encodedTransaction.raw
pending.rawTransactionId = encodedTransaction.raw
try self.repository.update(pending)
DispatchQueue.main.async {
result(.success(pending))
}
} catch StorageError.updateFailed {
DispatchQueue.main.async {
result(.failure(TransactionManagerError.updateFailed(tx: pendingTransaction)))
}
} catch {
DispatchQueue.main.async {
result(.failure(error))
}
}
}
}
func submit(pendingTransaction: PendingTransactionEntity, result: @escaping (Result<PendingTransactionEntity, Error>) -> Void) {
guard let txId = pendingTransaction.id else {
result(.failure(TransactionManagerError.notPending(tx: pendingTransaction)))// this transaction is not stored
return
}
// FIX: change to async when librustzcash is updated to v6
queue.sync { [weak self] in
guard let self = self else { return }
do {
guard let storedTx = try self.repository.find(by: txId) else {
result(.failure(TransactionManagerError.notPending(tx: pendingTransaction)))
return
}
guard !storedTx.isCancelled else {
print("ignoring cancelled transaction \(storedTx)")
result(.failure(TransactionManagerError.cancelled(tx: storedTx)))
return
}
guard let raw = storedTx.raw else {
print("INCONSISTENCY: attempt to send pending transaction \(txId) that has not raw data")
result(.failure(TransactionManagerError.internalInconsistency(tx: storedTx)))
return
}
let response = try self.service.submit(spendTransaction: raw)
let tx = try self.update(transaction: storedTx, on: response)
guard response.errorCode >= 0 else {
result(.failure(TransactionManagerError.submitFailed(tx: tx, errorCode: Int(response.errorCode))))
return
}
result(.success(tx))
} catch {
result(.failure(error))
}
}
}
func applyMinedHeight(pendingTransaction: PendingTransactionEntity, minedHeight: BlockHeight) throws -> PendingTransactionEntity {
guard let id = pendingTransaction.id else {
throw TransactionManagerError.internalInconsistency(tx: pendingTransaction)
}
guard var tx = try repository.find(by: id) else {
throw TransactionManagerError.notPending(tx: pendingTransaction)
}
tx.minedHeight = minedHeight
do {
try repository.update(tx)
} catch {
throw TransactionManagerError.updateFailed(tx: tx)
}
return tx
}
func monitorChanges(byId: Int, observer: Any) {
// TODO: Implement this
}
func cancel(pendingTransaction: PendingTransactionEntity) -> Bool {
guard let id = pendingTransaction.id else { return false }
guard let tx = try? repository.find(by: id) else { return false }
guard !tx.isSubmitted else { return false }
guard (try? repository.cancel(tx)) != nil else { return false }
return true
}
func allPendingTransactions() throws -> [PendingTransactionEntity]? {
try repository.getAll()
}
// MARK: other functions
private func updateOnFailure(tx: PendingTransactionEntity, error: Error) throws {
var pending = tx
pending.errorMessage = error.localizedDescription
pending.encodeAttempts = tx.encodeAttempts + 1
try self.repository.update(pending)
}
private func update(transaction: PendingTransactionEntity, on sendResponse: LightWalletServiceResponse) throws -> PendingTransactionEntity {
var tx = transaction
let error = sendResponse.errorCode < 0
tx.errorCode = Int(sendResponse.errorCode)
tx.errorMessage = error ? sendResponse.errorMessage : nil
try repository.update(tx)
return tx
}
}

View File

@ -10,8 +10,8 @@ import Foundation
typealias TransactionEncoderResultBlock = (_ result: Result<EncodedTransaction,Error>) -> Void
public enum TransactionEncoderError: Error {
case notFound(transactionId: Int64)
case NotEncoded(transactionId: Int64)
case notFound(transactionId: Int)
case NotEncoded(transactionId: Int)
case missingParams
case spendingKeyWrongNetwork
}
@ -24,7 +24,7 @@ protocol TransactionEncoder {
double-bangs for things).
Blocking
*/
func createTransaction(spendingKey: String, zatoshi: Int64, to: String, memo: String?, from accountIndex: Int) throws -> EncodedTransaction
func createTransaction(spendingKey: String, zatoshi: Int, to: String, memo: String?, from accountIndex: Int) throws -> EncodedTransaction
/**
Creates a transaction, throwing an exception whenever things are missing. When the provided wallet implementation
@ -32,6 +32,6 @@ protocol TransactionEncoder {
double-bangs for things).
Non-blocking
*/
func createTransaction(spendingKey: String, zatoshi: Int64, to: String, memo: String?, from accountIndex: Int, result: @escaping TransactionEncoderResultBlock)
func createTransaction(spendingKey: String, zatoshi: Int, to: String, memo: String?, from accountIndex: Int, result: @escaping TransactionEncoderResultBlock)
}

View File

@ -0,0 +1,34 @@
//
// TransactionManager.swift
// ZcashLightClientKit
//
// Created by Francisco Gindre on 11/26/19.
//
import Foundation
/**
Manage outbound transactions with the main purpose of reporting which ones are still pending,
particularly after failed attempts or dropped connectivity. The intent is to help see outbound
transactions through to completion.
*/
protocol OutboundTransactionManager {
func initSpend(zatoshi: Int, toAddress: String, memo: String?, from accountIndex: Int) throws -> PendingTransactionEntity
func encode(spendingKey: String, pendingTransaction: PendingTransactionEntity, result: @escaping (Result<PendingTransactionEntity, Error>) -> Void)
func submit(pendingTransaction: PendingTransactionEntity, result: @escaping (Result<PendingTransactionEntity, Error>) -> Void)
func applyMinedHeight(pendingTransaction: PendingTransactionEntity, minedHeight: BlockHeight) throws -> PendingTransactionEntity
func monitorChanges(byId: Int, observer: Any) // check this out. code smell
/**
Attempts to Cancel a transaction. Returns true if successful
*/
func cancel(pendingTransaction: PendingTransactionEntity) -> Bool
func allPendingTransactions() throws -> [PendingTransactionEntity]?
}

View File

@ -8,16 +8,19 @@
import Foundation
class WalletTransactionEncoder: TransactionEncoder {
var rustBackend: ZcashRustBackend.Type
var repository: TransactionRepository
var initializer: Initializer
var queue: DispatchQueue
init(rust: ZcashRustBackend.Type, repository: TransactionRepository, initializer: Initializer) {
self.rustBackend = rust
self.repository = repository
self.initializer = initializer
self.queue = DispatchQueue(label: "wallet.transaction.encoder.serial.queue")
}
func createTransaction(spendingKey: String, zatoshi: Int64, to: String, memo: String?, from accountIndex: Int) throws -> EncodedTransaction {
func createTransaction(spendingKey: String, zatoshi: Int, to: String, memo: String?, from accountIndex: Int) throws -> EncodedTransaction {
let txId = try createSpend(spendingKey: spendingKey, zatoshi: zatoshi, to: to, memo: memo, from: accountIndex)
@ -35,9 +38,9 @@ class WalletTransactionEncoder: TransactionEncoder {
}
}
func createTransaction(spendingKey: String, zatoshi: Int64, to: String, memo: String?, from accountIndex: Int, result: @escaping TransactionEncoderResultBlock) {
func createTransaction(spendingKey: String, zatoshi: Int, to: String, memo: String?, from accountIndex: Int, result: @escaping TransactionEncoderResultBlock) {
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
queue.async { [weak self] in
guard let self = self else { return }
do {
result(.success(try self.createTransaction(spendingKey: spendingKey, zatoshi: zatoshi, to: to, memo: memo, from: accountIndex)))
@ -47,7 +50,7 @@ class WalletTransactionEncoder: TransactionEncoder {
}
}
func createSpend(spendingKey: String, zatoshi: Int64, to address: String, memo: String?, from accountIndex: Int) throws -> Int64 {
func createSpend(spendingKey: String, zatoshi: Int, to address: String, memo: String?, from accountIndex: Int) throws -> Int {
guard ensureParams(spend: initializer.spendParamsURL, output: initializer.spendParamsURL),
let spend = URL(string: initializer.spendParamsURL.path), let output = URL(string: initializer.outputParamsURL.path) else {
throw TransactionEncoderError.missingParams
@ -60,7 +63,7 @@ class WalletTransactionEncoder: TransactionEncoder {
throw rustBackend.lastError() ?? RustWeldingError.genericError(message: "create spend failed")
}
return txId
return Int(txId)
}
func ensureParams(spend: URL, output: URL) -> Bool {

View File

@ -0,0 +1,219 @@
//
// OutboundTransactionManagerTests.swift
// ZcashLightClientKit-Unit-Tests
//
// Created by Francisco Gindre on 11/26/19.
//
import XCTest
@testable import ZcashLightClientKit
class OutboundTransactionManagerTests: XCTestCase {
var transactionManager: OutboundTransactionManager!
var encoder: TransactionEncoder!
var pendingRespository: PendingTransactionSQLDAO!
var dataDbHandle = TestDbHandle(originalDb: TestDbBuilder.prePopulatedDataDbURL()!)
var cacheDbHandle = TestDbHandle(originalDb: TestDbBuilder.prePopulatedCacheDbURL()!)
var pendingDbhandle = TestDbHandle(originalDb: try! TestDbBuilder.pendingTransactionsDbURL())
var service: LightWalletService!
var initializer: Initializer!
let spendingKey = "secret-extended-key-test1qvpevftsqqqqpqy52ut2vv24a2qh7nsukew7qg9pq6djfwyc3xt5vaxuenshp2hhspp9qmqvdh0gs2ljpwxders5jkwgyhgln0drjqaguaenfhehz4esdl4kwlm5t9q0l6wmzcrvcf5ed6dqzvct3e2ge7f6qdvzhp02m7sp5a0qjssrwpdh7u6tq89hl3wchuq8ljq8r8rwd6xdwh3nry9at80z7amnj3s6ah4jevnvfr08gxpws523z95g6dmn4wm6l3658kd4xcq9rc0qn"
let recipientAddress = "ztestsapling1ctuamfer5xjnnrdr3xdazenljx0mu0gutcf9u9e74tr2d3jwjnt0qllzxaplu54hgc2tyjdc2p6"
let zpend: Int = 500_000
override func setUp() {
try! dataDbHandle.setUp()
try! cacheDbHandle.setUp()
pendingRespository = PendingTransactionSQLDAO(dbProvider: pendingDbhandle.connectionProvider(readwrite: true))
try! pendingRespository.createrTableIfNeeded()
initializer = Initializer(cacheDbURL: cacheDbHandle.readWriteDb, dataDbURL: dataDbHandle.readWriteDb, endpoint: LightWalletEndpointBuilder.default, spendParamsURL: try! __spendParamsURL(), outputParamsURL: try! __outputParamsURL())
encoder = WalletTransactionEncoder(rust: ZcashRustBackend.self, repository: TestDbBuilder.transactionRepository()!, initializer: initializer)
transactionManager = PersistentTransactionManager(encoder: encoder, service: MockLightWalletService(latestBlockHeight: 620999), repository: pendingRespository)
}
override func tearDown() {
transactionManager = nil
encoder = nil
service = nil
initializer = nil
pendingRespository = nil
dataDbHandle.dispose()
cacheDbHandle.dispose()
pendingDbhandle.dispose()
}
func testInitSpend() {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct results.
var tx: PendingTransactionEntity?
XCTAssertNoThrow(try { tx = try transactionManager.initSpend(zatoshi: zpend, toAddress: recipientAddress, memo: nil, from: 0) }())
guard let pendingTx = tx else {
XCTFail("failed to create pending transaction")
return
}
XCTAssertEqual(pendingTx.toAddress, recipientAddress)
XCTAssertEqual(pendingTx.memo, nil)
XCTAssertEqual(pendingTx.value, zpend)
}
func testEncodeSpend() {
let expect = XCTestExpectation(description: self.description)
var tx: PendingTransactionEntity?
XCTAssertNoThrow(try { tx = try transactionManager.initSpend(zatoshi: zpend, toAddress: recipientAddress, memo: nil, from: 0) }())
guard let pendingTx = tx else {
XCTFail("failed to create pending transaction")
return
}
transactionManager.encode(spendingKey: spendingKey, pendingTransaction: pendingTx) { (result) in
expect.fulfill()
switch result {
case .failure(let error):
XCTFail("failed with error: \(error)")
case .success(let tx):
XCTAssertEqual(tx.id, pendingTx.id)
}
}
wait(for: [expect], timeout: 120)
}
func testSubmit() {
let encodeExpect = XCTestExpectation(description: "encode")
let submitExpect = XCTestExpectation(description: "submit")
var tx: PendingTransactionEntity?
XCTAssertNoThrow(try { tx = try transactionManager.initSpend(zatoshi: zpend, toAddress: recipientAddress, memo: nil, from: 0) }())
guard let pendingTx = tx else {
XCTFail("failed to create pending transaction")
return
}
var encodedTx: PendingTransactionEntity?
transactionManager.encode(spendingKey: spendingKey, pendingTransaction: pendingTx) { (result) in
encodeExpect.fulfill()
switch result {
case .failure(let error):
XCTFail("failed with error: \(error)")
case .success(let tx):
XCTAssertEqual(tx.id, pendingTx.id)
encodedTx = tx
}
}
wait(for: [encodeExpect], timeout: 500)
guard let submittableTx = encodedTx else {
XCTFail("failed to encode tx")
return
}
transactionManager.submit(pendingTransaction: submittableTx) { (result) in
submitExpect.fulfill()
switch result {
case .failure(let error):
XCTFail("submission failed with error: \(error)")
case .success(let successfulTx):
XCTAssertEqual(submittableTx.id, successfulTx.id)
}
}
wait(for: [submitExpect], timeout: 5)
}
func testApplyMinedHeight() {
var tx: PendingTransactionEntity?
let minedHeight = 789_000
XCTAssertNoThrow(try { tx = try transactionManager.initSpend(zatoshi: zpend, toAddress: recipientAddress, memo: nil, from: 0) }())
guard let pendingTx = tx else {
XCTFail("failed to create pending transaction")
return
}
var minedTransaction: PendingTransactionEntity?
XCTAssertNoThrow(try { minedTransaction = try transactionManager.applyMinedHeight(pendingTransaction: pendingTx, minedHeight: minedHeight)}())
guard let minedTx = minedTransaction else {
XCTFail("failed to apply mined height")
return
}
XCTAssertTrue(minedTx.isMined)
XCTAssertEqual(minedTx.minedHeight, minedHeight)
}
func testCancel() {
var tx: PendingTransactionEntity?
XCTAssertNoThrow(try { tx = try transactionManager.initSpend(zatoshi: zpend, toAddress: recipientAddress, memo: nil, from: 0) }())
guard let pendingTx = tx else {
XCTFail("failed to create pending transaction")
return
}
let cancellationResult = transactionManager.cancel(pendingTransaction: pendingTx)
guard let id = pendingTx.id else {
XCTFail("transaction with no id")
return
}
guard let retrievedTransaction = try! pendingRespository.find(by: id) else {
XCTFail("failed to retrieve previously created transation")
return
}
XCTAssertEqual(cancellationResult, retrievedTransaction.isCancelled)
}
func testAllPendingTransactions() {
let txCount = 100
for i in 0 ..< txCount {
var tx: PendingTransactionEntity?
XCTAssertNoThrow(try { tx = try transactionManager.initSpend(zatoshi: zpend, toAddress: recipientAddress, memo: nil, from: 0) }())
guard tx != nil else {
XCTFail("failed to create pending transaction \(i)")
return
}
}
guard let allPending = try! transactionManager.allPendingTransactions() else {
XCTFail("failed to retrieve all pending transactions")
return
}
XCTAssertEqual(allPending.count, txCount)
}
}

View File

@ -0,0 +1,174 @@
//
// PendingTransactionRepositoryTests.swift
// ZcashLightClientKit-Unit-Tests
//
// Created by Francisco Gindre on 11/27/19.
//
import XCTest
@testable import ZcashLightClientKit
class PendingTransactionRepositoryTests: XCTestCase {
var pendingRepository: PendingTransactionRepository!
let dbUrl = try! TestDbBuilder.pendingTransactionsDbURL()
let recipientAddress = "ztestsapling1ctuamfer5xjnnrdr3xdazenljx0mu0gutcf9u9e74tr2d3jwjnt0qllzxaplu54hgc2tyjdc2p6"
override func setUp() {
cleanUpDb()
let dao = PendingTransactionSQLDAO(dbProvider: SimpleConnectionProvider(path: try! TestDbBuilder.pendingTransactionsDbURL().absoluteString))
try! dao.createrTableIfNeeded()
pendingRepository = dao
}
override func tearDown() {
cleanUpDb()
}
func cleanUpDb() {
try? FileManager.default.removeItem(at: TestDbBuilder.pendingTransactionsDbURL())
}
func testCreate() {
let tx = createAndStoreMockedTransaction()
guard let id = tx.id, id >= 0 else {
XCTFail("failed to create mocked transaction that was just inserted")
return
}
var expectedTx: PendingTransactionEntity?
XCTAssertNoThrow(try { expectedTx = try pendingRepository.find(by: id)}())
guard let expected = expectedTx else {
XCTFail("failed to retrieve mocked transaction by id \(id) that was just inserted")
return
}
XCTAssertEqual(tx.accountIndex, expected.accountIndex)
XCTAssertEqual(tx.value, expected.value)
XCTAssertEqual(tx.toAddress, expected.toAddress)
}
func testFindById() {
let tx = createAndStoreMockedTransaction()
var expected: PendingTransactionEntity?
guard let id = tx.id else {
XCTFail("transaction with no id")
return
}
XCTAssertNoThrow(try { expected = try pendingRepository.find(by: id)}())
XCTAssertNotNil(expected)
}
func testCancel() {
let tx = createAndStoreMockedTransaction()
guard let id = tx.id else {
XCTFail("transaction with no id")
return
}
guard id >= 0 else {
XCTFail("failed to create mocked transaction that was just inserted")
return
}
XCTAssertNoThrow(try pendingRepository.cancel(tx))
}
func testDelete() {
let tx = createAndStoreMockedTransaction()
guard let id = tx.id else {
XCTFail("transaction with no id")
return
}
guard id >= 0 else {
XCTFail("failed to create mocked transaction that was just inserted")
return
}
XCTAssertNoThrow(try pendingRepository.delete(tx))
var unexpectedTx: PendingTransactionEntity?
XCTAssertNoThrow(try { unexpectedTx = try pendingRepository.find(by: id) }())
XCTAssertNil(unexpectedTx)
}
func testGetAll() {
var mockTransactions = [PendingTransactionEntity]()
for _ in 1...100 {
mockTransactions.append(createAndStoreMockedTransaction())
}
var all: [PendingTransactionEntity]?
XCTAssertNoThrow(try { all = try pendingRepository.getAll() }())
guard let allTxs = all else {
XCTFail("failed to get all transactions")
return
}
XCTAssertEqual(mockTransactions.count, allTxs.count)
}
func testUpdate() {
let newAccountIndex = 1
let newValue: Int = 123_456
let tx = createAndStoreMockedTransaction()
guard let id = tx.id else {
XCTFail("transaction with no id")
return
}
var stored: PendingTransactionEntity?
XCTAssertNoThrow(try { stored = try pendingRepository.find(by: id)}())
guard stored != nil else {
XCTFail("failed to store tx")
return
}
stored!.accountIndex = newAccountIndex
stored!.value = newValue
XCTAssertNoThrow(try pendingRepository.update(stored!))
guard let updatedTransaction = try? pendingRepository.find(by: stored!.id!) else {
XCTFail("failed to retrieve updated transaction with id: \(stored!.id!)")
return
}
XCTAssertEqual(updatedTransaction.value, newValue)
XCTAssertEqual(updatedTransaction.accountIndex, newAccountIndex)
XCTAssertEqual(updatedTransaction.toAddress, stored!.toAddress)
}
func createAndStoreMockedTransaction() -> PendingTransactionEntity {
var tx = mockTransaction()
var id: Int?
XCTAssertNoThrow(try { id = try pendingRepository.create(tx) }())
tx.id = Int(id ?? -1)
return tx
}
func testPerformanceExample() {
// This is an example of a performance test case.
self.measure {
_ = try! pendingRepository.getAll()
}
}
private func mockTransaction() -> PendingTransactionEntity {
PendingTransaction(value: Int.random(in: 1 ... 1_000_000), toAddress: recipientAddress, memo: nil, account: 0)
}
}

View File

@ -17,7 +17,7 @@ class WalletTransactionEncoderTests: XCTestCase {
var initializer: Initializer!
let spendingKey = "secret-extended-key-test1qvpevftsqqqqpqy52ut2vv24a2qh7nsukew7qg9pq6djfwyc3xt5vaxuenshp2hhspp9qmqvdh0gs2ljpwxders5jkwgyhgln0drjqaguaenfhehz4esdl4kwlm5t9q0l6wmzcrvcf5ed6dqzvct3e2ge7f6qdvzhp02m7sp5a0qjssrwpdh7u6tq89hl3wchuq8ljq8r8rwd6xdwh3nry9at80z7amnj3s6ah4jevnvfr08gxpws523z95g6dmn4wm6l3658kd4xcq9rc0qn"
let recipientAddress = "ztestsapling1ctuamfer5xjnnrdr3xdazenljx0mu0gutcf9u9e74tr2d3jwjnt0qllzxaplu54hgc2tyjdc2p6"
let zpend: Int64 = 500_000
let zpend: Int = 500_000
override func setUp() {
try! dataDbHandle.setUp()
@ -54,7 +54,7 @@ class WalletTransactionEncoderTests: XCTestCase {
XCTAssert(initializer.getBalance() >= zpend)
var spendId: Int64?
var spendId: Int?
XCTAssertNoThrow(try { spendId = try transactionEncoder.createSpend(spendingKey: self.spendingKey, zatoshi: self.zpend, to: self.recipientAddress, memo: nil, from: 0) }())

View File

@ -11,16 +11,20 @@ import XCTest
class ZcashRustBackendTests: XCTestCase {
var dbData: URL!
var dataDbHandle = TestDbHandle(originalDb: TestDbBuilder.prePopulatedDataDbURL()!)
var cacheDbHandle = TestDbHandle(originalDb: TestDbBuilder.prePopulatedCacheDbURL()!)
let spendingKey = "secret-extended-key-test1qvpevftsqqqqpqy52ut2vv24a2qh7nsukew7qg9pq6djfwyc3xt5vaxuenshp2hhspp9qmqvdh0gs2ljpwxders5jkwgyhgln0drjqaguaenfhehz4esdl4kwlm5t9q0l6wmzcrvcf5ed6dqzvct3e2ge7f6qdvzhp02m7sp5a0qjssrwpdh7u6tq89hl3wchuq8ljq8r8rwd6xdwh3nry9at80z7amnj3s6ah4jevnvfr08gxpws523z95g6dmn4wm6l3658kd4xcq9rc0qn"
let recipientAddress = "ztestsapling1ctuamfer5xjnnrdr3xdazenljx0mu0gutcf9u9e74tr2d3jwjnt0qllzxaplu54hgc2tyjdc2p6"
let zpend: Int = 500_000
override func setUp() {
dbData = try! __dataDbURL()
try? dataDbHandle.setUp()
}
override func tearDown() {
try? FileManager.default.removeItem(at: dbData!)
dataDbHandle.dispose()
}
func testInitAndGetAddress() {
@ -60,4 +64,11 @@ class ZcashRustBackendTests: XCTestCase {
XCTAssertTrue(ZcashRustBackend.scanBlocks(dbCache: cacheDb, dbData: dbData))
}
func testSendToAddress() {
let tx = try! ZcashRustBackend.sendToAddress(dbData: dataDbHandle.readWriteDb, account: 0, extsk: spendingKey, to: recipientAddress, value: Int64(zpend), memo: nil, spendParams: URL(string: __spendParamsURL().path)!, outputParams: URL(string: __outputParamsURL().path)!)
XCTAssert(tx > 0)
XCTAssertNil(ZcashRustBackend.lastError())
}
}

View File

@ -7,7 +7,15 @@
//
import Foundation
import SwiftProtobuf
@testable import ZcashLightClientKit
struct LightWalletServiceMockResponse: LightWalletServiceResponse {
var errorCode: Int32
var errorMessage: String
var unknownFields: UnknownStorage
}
class MockLightWalletService: LightWalletService {
private var service = LightWalletGRPCService(channel: ChannelProvider().channel())
@ -34,5 +42,13 @@ class MockLightWalletService: LightWalletService {
try self.service.blockRange(range)
}
func submit(spendTransaction: Data, result: @escaping (Result<LightWalletServiceResponse, LightWalletServiceError>) -> Void) {
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 1) {
result(.success(LightWalletServiceMockResponse(errorCode: 0, errorMessage: "", unknownFields: UnknownStorage())))
}
}
func submit(spendTransaction: Data) throws -> LightWalletServiceResponse {
return LightWalletServiceMockResponse(errorCode: 0, errorMessage: "", unknownFields: UnknownStorage())
}
}

View File

@ -31,6 +31,19 @@ class AwfulLightWalletService: LightWalletService {
}
}
func submit(spendTransaction: Data, result: @escaping(Result<LightWalletServiceResponse,LightWalletServiceError>) -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
result(.failure(LightWalletServiceError.generalError))
}
}
/**
Submits a raw transaction over lightwalletd. Blocking
*/
func submit(spendTransaction: Data) throws -> LightWalletServiceResponse {
throw LightWalletServiceError.generalError
}
}
class MockRustBackend: ZcashRustBackendWelding {

View File

@ -50,6 +50,9 @@ class TestDbBuilder {
return compactBlockDao
}
static func pendingTransactionsDbURL() throws -> URL {
try __documentsDirectory().appendingPathComponent("pending.db")
}
static func prePopulatedCacheDbURL() -> URL? {
Bundle(for: TestDbBuilder.self).url(forResource: "cache", withExtension: "db")
}