// // AdvancedReOrgTests.swift // ZcashLightClientKit-Unit-Tests // // Created by Francisco Gindre on 5/14/20. // import XCTest @testable import TestUtils @testable import ZcashLightClientKit // swiftlint:disable implicitly_unwrapped_optional force_unwrapping type_body_length //@MainActor class AdvancedReOrgTests: XCTestCase { // TODO: Parameterize this from environment? // swiftlint:disable:next line_length var seedPhrase = "still champion voice habit trend flight survey between bitter process artefact blind carbon truly provide dizzy crush flush breeze blouse charge solid fish spread" // TODO: Parameterize this from environment let testRecipientAddress = "zs17mg40levjezevuhdp5pqrd52zere7r7vrjgdwn5sj4xsqtm20euwahv9anxmwr3y3kmwuz8k55a" let sendAmount = Zatoshi(1000) var birthday: BlockHeight = 663150 let defaultLatestHeight: BlockHeight = 663175 var coordinator: TestCoordinator! var syncedExpectation = XCTestExpectation(description: "synced") var sentTransactionExpectation = XCTestExpectation(description: "sent") var expectedReorgHeight: BlockHeight = 665188 var expectedRewindHeight: BlockHeight = 665188 var reorgExpectation = XCTestExpectation(description: "reorg") let branchID = "2bb40e60" let chainName = "main" let network = DarksideWalletDNetwork() override func setUpWithError() throws { try super.setUpWithError() wait { [self] in self.coordinator = try await TestCoordinator( seed: seedPhrase, walletBirthday: birthday + 50, //don't use an exact birthday, users never do. channelProvider: ChannelProvider(), network: network ) try coordinator.reset(saplingActivation: 663150, branchID: self.branchID, chainName: self.chainName) } } override func tearDownWithError() throws { try super.tearDownWithError() NotificationCenter.default.removeObserver(self) try coordinator.stop() try? FileManager.default.removeItem(at: coordinator.databases.cacheDB) try? FileManager.default.removeItem(at: coordinator.databases.dataDB) try? FileManager.default.removeItem(at: coordinator.databases.pendingDB) } @objc func handleReorg(_ notification: Notification) { guard let reorgHeight = notification.userInfo?[CompactBlockProcessorNotificationKey.reorgHeight] as? BlockHeight, let rewindHeight = notification.userInfo?[CompactBlockProcessorNotificationKey.rewindHeight] as? BlockHeight else { XCTFail("empty reorg notification") return } logger!.debug("--- REORG DETECTED \(reorgHeight)--- RewindHeight: \(rewindHeight)", file: #file, function: #function, line: #line) XCTAssertEqual(reorgHeight, expectedReorgHeight) reorgExpectation.fulfill() } /// pre-condition: know balances before tx at received_Tx_height arrives /// 1. Setup w/ default dataset /// 2. applyStaged(received_Tx_height) /// 3. sync up to received_Tx_height /// 3a. verify that balance is previous balance + tx amount /// 4. get that transaction hex encoded data /// 5. stage 5 empty blocks w/heights received_Tx_height to received_Tx_height + 3 /// 6. stage tx at received_Tx_height + 3 /// 6a. applyheight(received_Tx_height + 1) /// 7. sync to received_Tx_height + 1 /// 8. assert that reorg happened at received_Tx_height /// 9. verify that balance equals initial balance /// 10. sync up to received_Tx_height + 3 /// 11. verify that balance equals initial balance + tx amount func testReOrgChangesInboundTxMinedHeight() async throws { hookToReOrgNotification() try FakeChainBuilder.buildChain(darksideWallet: coordinator.service, branchID: branchID, chainName: chainName) var shouldContinue = false let receivedTxHeight: BlockHeight = 663188 var initialTotalBalance = Zatoshi(-1) var initialVerifiedBalance = Zatoshi(-1) self.expectedReorgHeight = receivedTxHeight + 1 /* precondition:know balances before tx at received_Tx_height arrives */ try coordinator.applyStaged(blockheight: receivedTxHeight - 1) sleep(3) let preTxExpectation = XCTestExpectation(description: "pre receive") var synchronizer: SDKSynchronizer? try await withCheckedThrowingContinuation { continuation in do { try coordinator.sync( completion: { synchro in synchronizer = synchro initialVerifiedBalance = synchro.initializer.getVerifiedBalance() initialTotalBalance = synchro.initializer.getBalance() preTxExpectation.fulfill() shouldContinue = true continuation.resume() }, error: self.handleError ) } catch { continuation.resume(with: .failure(error)) } } wait(for: [preTxExpectation], timeout: 10) guard shouldContinue else { XCTFail("pre receive sync failed") return } /* 2. applyStaged(received_Tx_height) */ try coordinator.applyStaged(blockheight: receivedTxHeight) sleep(2) /* 3. sync up to received_Tx_height */ let receivedTxExpectation = XCTestExpectation(description: "received tx") var receivedTxTotalBalance = Zatoshi(-1) var receivedTxVerifiedBalance = Zatoshi(-1) try await withCheckedThrowingContinuation { continuation in do { try coordinator.sync(completion: { synchro in synchronizer = synchro receivedTxVerifiedBalance = synchro.initializer.getVerifiedBalance() receivedTxTotalBalance = synchro.initializer.getBalance() receivedTxExpectation.fulfill() continuation.resume() }, error: self.handleError) } catch { continuation.resume(with: .failure(error)) } } sleep(2) wait(for: [receivedTxExpectation], timeout: 10) guard let syncedSynchronizer = synchronizer else { XCTFail("nil synchronizer") return } sleep(5) guard let receivedTx = syncedSynchronizer.receivedTransactions.first, receivedTx.minedHeight == receivedTxHeight else { XCTFail("did not receive transaction") return } /* 3a. verify that balance is previous balance + tx amount */ XCTAssertEqual(receivedTxTotalBalance, initialTotalBalance + receivedTx.value) XCTAssertEqual(receivedTxVerifiedBalance, initialVerifiedBalance) /* 4. get that transaction hex encoded data */ guard let receivedTxData = receivedTx.raw else { XCTFail("received tx has no raw data!") return } let receivedRawTx = RawTransaction.with { rawTx in rawTx.height = UInt64(receivedTxHeight) rawTx.data = receivedTxData } /* 5. stage 5 empty blocks w/heights received_Tx_height to received_Tx_height + 4 */ try coordinator.stageBlockCreate(height: receivedTxHeight, count: 5) /* 6. stage tx at received_Tx_height + 3 */ let reorgedTxheight = receivedTxHeight + 2 try coordinator.stageTransaction(receivedRawTx, at: reorgedTxheight) /* 6a. applyheight(received_Tx_height + 1) */ try coordinator.applyStaged(blockheight: receivedTxHeight + 1) sleep(2) /* 7. sync to received_Tx_height + 1 */ let reorgSyncexpectation = XCTestExpectation(description: "reorg expectation") var afterReorgTxTotalBalance = Zatoshi(-1) var afterReorgTxVerifiedBalance = Zatoshi(-1) try await withCheckedThrowingContinuation { continuation in do { try coordinator.sync( completion: { synchronizer in afterReorgTxTotalBalance = synchronizer.initializer.getBalance() afterReorgTxVerifiedBalance = synchronizer.initializer.getVerifiedBalance() reorgSyncexpectation.fulfill() continuation.resume() }, error: self.handleError ) } catch { continuation.resume(with: .failure(error)) } } /* 8. assert that reorg happened at received_Tx_height */ sleep(2) wait(for: [reorgExpectation, reorgSyncexpectation], timeout: 5, enforceOrder: false) /* 9. verify that balance equals initial balance */ XCTAssertEqual(afterReorgTxVerifiedBalance, initialVerifiedBalance) XCTAssertEqual(afterReorgTxTotalBalance, initialTotalBalance) /* 10. sync up to received_Tx_height + 3 */ let finalsyncExpectation = XCTestExpectation(description: "final sync") var finalReorgTxTotalBalance = Zatoshi(-1) var finalReorgTxVerifiedBalance = Zatoshi(-1) try coordinator.applyStaged(blockheight: reorgedTxheight + 1) sleep(3) try await withCheckedThrowingContinuation { continuation in do { try coordinator.sync( completion: { synchronizer in finalReorgTxTotalBalance = synchronizer.initializer.getBalance() finalReorgTxVerifiedBalance = synchronizer.initializer.getVerifiedBalance() finalsyncExpectation.fulfill() continuation.resume() }, error: self.handleError ) } catch { continuation.resume(with: .failure(error)) } } wait(for: [finalsyncExpectation], timeout: 5) sleep(3) guard let reorgedTx = coordinator.synchronizer.receivedTransactions.first else { XCTFail("no transactions found") return } XCTAssertEqual(reorgedTx.minedHeight, reorgedTxheight) XCTAssertEqual(initialVerifiedBalance, finalReorgTxVerifiedBalance) XCTAssertEqual(initialTotalBalance + receivedTx.value, finalReorgTxTotalBalance) } /// An outbound, unconfirmed transaction in a specific block changes height in the event of a reorg /// /// /// The wallet handles this change, reflects it appropriately in local storage, and funds remain spendable post confirmation. /// /// Pre-conditions: /// - Wallet has spendable funds /// /// 1. Setup w/ default dataset /// 2. applyStaged(received_Tx_height) /// 3. sync up to received_Tx_height /// 4. create transaction /// 5. stage 10 empty blocks /// 6. submit tx at sentTxHeight /// a. getIncomingTx /// b. stageTransaction(sentTx, sentTxHeight) /// c. applyheight(sentTxHeight + 1 ) /// 7. sync to sentTxHeight + 2 /// 8. stage sentTx and otherTx at sentTxheight /// 9. applyStaged(sentTx + 2) /// 10. sync up to received_Tx_height + 2 /// 11. verify that the sent tx is mined and balance is correct /// 12. applyStaged(sentTx + 10) /// 13. verify that there's no more pending transaction func testReorgChangesOutboundTxIndex() async throws { try FakeChainBuilder.buildChain(darksideWallet: self.coordinator.service, branchID: branchID, chainName: chainName) let receivedTxHeight: BlockHeight = 663188 var initialTotalBalance = Zatoshi(-1) /* 2. applyStaged(received_Tx_height) */ try coordinator.applyStaged(blockheight: receivedTxHeight) sleep(2) let preTxExpectation = XCTestExpectation(description: "pre receive") /* 3. sync up to received_Tx_height */ try await withCheckedThrowingContinuation { continuation in do { try coordinator.sync(completion: { synchronizer in initialTotalBalance = synchronizer.initializer.getBalance() continuation.resume() preTxExpectation.fulfill() }, error: self.handleError) } catch { continuation.resume(throwing: error) } } wait(for: [preTxExpectation], timeout: 5) let sendExpectation = XCTestExpectation(description: "sendToAddress") var pendingEntity: PendingTransactionEntity? var testError: Error? let sendAmount = Zatoshi(10000) /* 4. create transaction */ do { let pendingTx = try await coordinator.synchronizer.sendToAddress( spendingKey: coordinator.spendingKeys!.first!, zatoshi: sendAmount, toAddress: try Recipient(testRecipientAddress, network: self.network.networkType), memo: try Memo(string: "test transaction") ) pendingEntity = pendingTx sendExpectation.fulfill() } catch { testError = error XCTFail("error sending to address. Error: \(String(describing: error))") } wait(for: [sendExpectation], timeout: 2) guard let pendingTx = pendingEntity else { XCTFail("error sending to address. Error: \(String(describing: testError))") return } /* 5. stage 10 empty blocks */ try coordinator.stageBlockCreate(height: receivedTxHeight + 1, count: 10) let sentTxHeight = receivedTxHeight + 1 /* 6. stage sent tx at sentTxHeight */ guard let sentTx = try coordinator.getIncomingTransactions()?.first else { XCTFail("sent transaction not present on Darksidewalletd") return } try coordinator.stageTransaction(sentTx, at: sentTxHeight) /* 6a. applyheight(sentTxHeight + 1 ) */ try coordinator.applyStaged(blockheight: sentTxHeight + 1) sleep(2) /* 7. sync to sentTxHeight + 1 */ let sentTxSyncExpectation = XCTestExpectation(description: "sent tx sync expectation") try await withCheckedThrowingContinuation { continuation in do { try coordinator.sync(completion: { synchronizer in let pMinedHeight = synchronizer.pendingTransactions.first?.minedHeight XCTAssertEqual(pMinedHeight, sentTxHeight) continuation.resume() sentTxSyncExpectation.fulfill() }, error: self.handleError) } catch { continuation.resume(throwing: error) } } wait(for: [sentTxSyncExpectation], timeout: 5) /* 8. stage sentTx and otherTx at sentTxheight */ try coordinator.stageBlockCreate(height: sentTxHeight, count: 20, nonce: 5) try coordinator.stageTransaction(url: FakeChainBuilder.someOtherTxUrl, at: sentTxHeight) try coordinator.stageTransaction(sentTx, at: sentTxHeight) /* 9. applyStaged(sentTx + 1) */ try coordinator.applyStaged(blockheight: sentTxHeight + 1) sleep(2) let afterReOrgExpectation = XCTestExpectation(description: "after ReOrg Expectation") try await withCheckedThrowingContinuation { continuation in do { try coordinator.sync(completion: { synchronizer in /* 11. verify that the sent tx is mined and balance is correct */ let pMinedHeight = synchronizer.pendingTransactions.first?.minedHeight XCTAssertEqual(pMinedHeight, sentTxHeight) XCTAssertEqual(initialTotalBalance - sendAmount - Zatoshi(1000), synchronizer.initializer.getBalance()) // fee change on this branch continuation.resume() afterReOrgExpectation.fulfill() }, error: self.handleError) } catch { continuation.resume(throwing: error) } } wait(for: [afterReOrgExpectation], timeout: 5) /* 12. applyStaged(sentTx + 10) */ try coordinator.applyStaged(blockheight: sentTxHeight + 12) sleep(2) /* 13. verify that there's no more pending transaction */ let lastSyncExpectation = XCTestExpectation(description: "sync to confirmation") try await withCheckedThrowingContinuation { continuation in do { try coordinator.sync(completion: { synchronizer in lastSyncExpectation.fulfill() continuation.resume() }, error: self.handleError) } catch { continuation.resume(throwing: error) } } wait(for: [lastSyncExpectation], timeout: 5) XCTAssertEqual(coordinator.synchronizer.pendingTransactions.count, 0) XCTAssertEqual(initialTotalBalance - pendingTx.value - Zatoshi(1000), coordinator.synchronizer.initializer.getVerifiedBalance()) let resultingBalance: Zatoshi = coordinator.synchronizer.initializer.getBalance() XCTAssertEqual(resultingBalance, coordinator.synchronizer.initializer.getVerifiedBalance()) } func testIncomingTransactionIndexChange() throws { hookToReOrgNotification() self.expectedReorgHeight = 663196 self.expectedRewindHeight = 663175 try coordinator.reset(saplingActivation: birthday, branchID: "2bb40e60", chainName: "main") try coordinator.resetBlocks(dataset: .predefined(dataset: .txIndexChangeBefore)) try coordinator.applyStaged(blockheight: 663195) sleep(1) let firstSyncExpectation = XCTestExpectation(description: "first sync expectation") var preReorgTotalBalance = Zatoshi.zero var preReorgVerifiedBalance = Zatoshi.zero try coordinator.sync(completion: { synchronizer in preReorgTotalBalance = synchronizer.initializer.getBalance() preReorgVerifiedBalance = synchronizer.initializer.getVerifiedBalance() firstSyncExpectation.fulfill() }, error: self.handleError) wait(for: [firstSyncExpectation], timeout: 10) /* trigger reorg */ try coordinator.resetBlocks(dataset: .predefined(dataset: .txIndexChangeAfter)) try coordinator.applyStaged(blockheight: 663200) sleep(1) let afterReorgSync = XCTestExpectation(description: "after reorg sync") var postReorgTotalBalance = Zatoshi.zero var postReorgVerifiedBalance = Zatoshi.zero try coordinator.sync(completion: { synchronizer in postReorgTotalBalance = synchronizer.initializer.getBalance() postReorgVerifiedBalance = synchronizer.initializer.getVerifiedBalance() afterReorgSync.fulfill() }, error: self.handleError) wait(for: [reorgExpectation, afterReorgSync], timeout: 30) XCTAssertEqual(postReorgVerifiedBalance, preReorgVerifiedBalance) XCTAssertEqual(postReorgTotalBalance, preReorgTotalBalance) } func testReOrgExpiresInboundTransaction() throws { try FakeChainBuilder.buildChain(darksideWallet: coordinator.service, branchID: branchID, chainName: chainName) let receivedTxHeight = BlockHeight(663188) try coordinator.applyStaged(blockheight: receivedTxHeight - 1) sleep(2) let expectation = XCTestExpectation(description: "sync to \(receivedTxHeight - 1) expectation") var initialBalance = Zatoshi(-1) var initialVerifiedBalance = Zatoshi(-1) try coordinator.sync(completion: { synchronizer in initialBalance = synchronizer.initializer.getBalance() initialVerifiedBalance = synchronizer.initializer.getVerifiedBalance() expectation.fulfill() }, error: self.handleError) wait(for: [expectation], timeout: 5) let afterTxHeight = receivedTxHeight + 1 try coordinator.applyStaged(blockheight: afterTxHeight) sleep(2) let afterTxSyncExpectation = XCTestExpectation(description: "sync to \(afterTxHeight) expectation") var afterTxBalance = Zatoshi(-1) var afterTxVerifiedBalance = Zatoshi(-1) try coordinator.sync(completion: { synchronizer in afterTxBalance = synchronizer.initializer.getBalance() afterTxVerifiedBalance = synchronizer.initializer.getVerifiedBalance() XCTAssertNotNil( synchronizer.receivedTransactions.first { $0.minedHeight == receivedTxHeight }, "Transaction not found at \(receivedTxHeight)" ) afterTxSyncExpectation.fulfill() }, error: self.handleError) wait(for: [afterTxSyncExpectation], timeout: 10.0) XCTAssertEqual(initialVerifiedBalance, afterTxVerifiedBalance) XCTAssertNotEqual(initialBalance, afterTxBalance) let reorgSize: Int = 3 let newBlocksCount: Int = 11 + reorgSize try coordinator.stageBlockCreate(height: receivedTxHeight - reorgSize, count: newBlocksCount + reorgSize) try coordinator.applyStaged(blockheight: receivedTxHeight + newBlocksCount - 1) sleep(2) let afterReorgExpectation = XCTestExpectation(description: "after reorg expectation") var afterReOrgBalance = Zatoshi(-1) var afterReOrgVerifiedBalance = Zatoshi(-1) try coordinator.sync(completion: { synchronizer in afterReOrgBalance = synchronizer.initializer.getBalance() afterReOrgVerifiedBalance = synchronizer.initializer.getVerifiedBalance() XCTAssertNil( synchronizer.receivedTransactions.first { $0.minedHeight == receivedTxHeight }, "Transaction found at \(receivedTxHeight) after reorg" ) afterReorgExpectation.fulfill() }, error: self.handleError) wait(for: [afterReorgExpectation], timeout: 5) XCTAssertEqual(afterReOrgBalance, initialBalance) XCTAssertEqual(afterReOrgVerifiedBalance, initialVerifiedBalance) } /// Steps: /// 1. sync up to an incoming transaction (incomingTxHeight + 1) /// 1a. save balances /// 2. stage 4 blocks from incomingTxHeight - 1 with different nonce /// 3. stage otherTx at incomingTxHeight /// 4. stage incomingTx at incomingTxHeight /// 5. applyHeight(incomingHeight + 3) /// 6. sync to latest height /// 7. check that balances still match func testReOrgChangesInboundTxIndexInBlock() throws { try FakeChainBuilder.buildChain(darksideWallet: coordinator.service, branchID: branchID, chainName: chainName) let incomingTxHeight = BlockHeight(663188) try coordinator.applyStaged(blockheight: incomingTxHeight + 1) /* 1. sync up to an incoming transaction (incomingTxHeight + 1) */ let firstSyncExpectation = XCTestExpectation(description: "first sync test expectation") var initialBalance = Zatoshi(-1) var initialVerifiedBalance = Zatoshi(-1) var incomingTx: ConfirmedTransactionEntity? try coordinator.sync(completion: { _ in firstSyncExpectation.fulfill() }, error: self.handleError) wait(for: [firstSyncExpectation], timeout: 5) /* 1a. save balances */ initialBalance = coordinator.synchronizer.initializer.getBalance() initialVerifiedBalance = coordinator.synchronizer.initializer.getVerifiedBalance() incomingTx = coordinator.synchronizer.receivedTransactions.first(where: { $0.minedHeight == incomingTxHeight }) guard let transaction = incomingTx else { XCTFail("no tx found") return } guard let txRawData = transaction.raw else { XCTFail("transaction has no raw data") return } let rawTransaction = RawTransaction.with({ rawTx in rawTx.data = txRawData }) /* 2. stage 4 blocks from incomingTxHeight - 1 with different nonce */ let blockCount = 4 try coordinator.stageBlockCreate(height: incomingTxHeight - 1, count: blockCount, nonce: Int.random(in: 0 ... Int.max)) /* 3. stage otherTx at incomingTxHeight */ try coordinator.stageTransaction(url: FakeChainBuilder.someOtherTxUrl, at: incomingTxHeight) /* 4. stage incomingTx at incomingTxHeight 5. applyHeight(incomingHeight + 3) 6. sync to latest height 7. check that balances still match */ try coordinator.stageTransaction(rawTransaction, at: incomingTxHeight) /* 5. applyHeight(incomingHeight + 2) */ try coordinator.applyStaged(blockheight: incomingTxHeight + 2) let lastSyncExpectation = XCTestExpectation(description: "last sync expectation") /* 6. sync to latest height */ try coordinator.sync(completion: { _ in lastSyncExpectation.fulfill() }, error: self.handleError) /* 7. check that balances still match */ XCTAssertEqual(coordinator.synchronizer.initializer.getVerifiedBalance(), initialVerifiedBalance) XCTAssertEqual(coordinator.synchronizer.initializer.getBalance(), initialBalance) wait(for: [lastSyncExpectation], timeout: 5) } func testTxIndexReorg() throws { try coordinator.resetBlocks(dataset: .predefined(dataset: .txIndexChangeBefore)) let txReorgHeight = BlockHeight(663195) let finalHeight = BlockHeight(663200) try coordinator.applyStaged(blockheight: txReorgHeight) let firstSyncExpectation = XCTestExpectation(description: "first sync test expectation") var initialBalance = Zatoshi(-1) var initialVerifiedBalance = Zatoshi(-1) try coordinator.sync(completion: { synchronizer in initialBalance = synchronizer.initializer.getBalance() initialVerifiedBalance = synchronizer.initializer.getVerifiedBalance() firstSyncExpectation.fulfill() }, error: self.handleError) wait(for: [firstSyncExpectation], timeout: 5) try coordinator.resetBlocks(dataset: .predefined(dataset: .txIndexChangeAfter)) try coordinator.applyStaged(blockheight: finalHeight) let lastSyncExpectation = XCTestExpectation(description: "last sync expectation") try coordinator.sync(completion: { _ in lastSyncExpectation.fulfill() }, error: self.handleError) wait(for: [lastSyncExpectation], timeout: 5) XCTAssertEqual(coordinator.synchronizer.initializer.getBalance(), initialBalance) XCTAssertEqual(coordinator.synchronizer.initializer.getVerifiedBalance(), initialVerifiedBalance) } /// A Re Org occurs and changes the height of an outbound transaction /// Pre-condition: Wallet has funds /// /// Steps: /// 1. create fake chain /// 1a. sync to latest height /// 2. send transaction to recipient address /// 3. getIncomingTransaction /// 4. stage transaction at sentTxHeight /// 5. applyHeight(sentTxHeight) /// 6. sync to latest height /// 6a. verify that there's a pending transaction with a mined height of sentTxHeight /// 7. stage 15 blocks from sentTxHeight /// 7. a stage sent tx to sentTxHeight + 2 /// 8. applyHeight(sentTxHeight + 1) to cause a 1 block reorg /// 9. sync to latest height /// 10. verify that there's a pending transaction with -1 mined height /// 11. applyHeight(sentTxHeight + 2) /// 11a. sync to latest height /// 12. verify that there's a pending transaction with a mined height of sentTxHeight + 2 /// 13. apply height(sentTxHeight + 15) /// 14. sync to latest height /// 15. verify that there's no pending transaction and that the tx is displayed on the sentTransactions collection func testReOrgChangesOutboundTxMinedHeight() async throws { hookToReOrgNotification() /* 1. create fake chain */ try FakeChainBuilder.buildChain(darksideWallet: coordinator.service, branchID: branchID, chainName: chainName) try coordinator.applyStaged(blockheight: 663188) sleep(2) let firstSyncExpectation = XCTestExpectation(description: "first sync") /* 1a. sync to latest height */ try await withCheckedThrowingContinuation { continuation in do { try coordinator.sync(completion: { synchronizer in firstSyncExpectation.fulfill() continuation.resume() }, error: self.handleError) } catch { continuation.resume(throwing: error) } } wait(for: [firstSyncExpectation], timeout: 5) sleep(1) let initialTotalBalance: Zatoshi = coordinator.synchronizer.initializer.getBalance() let sendExpectation = XCTestExpectation(description: "send expectation") var pendingEntity: PendingTransactionEntity? /* 2. send transaction to recipient address */ do { let pendingTx = try await coordinator.synchronizer.sendToAddress( spendingKey: self.coordinator.spendingKeys!.first!, zatoshi: Zatoshi(20000), toAddress: try Recipient(testRecipientAddress, network: self.network.networkType), memo: try Memo(string: "this is a test") ) pendingEntity = pendingTx sendExpectation.fulfill() } catch { self.handleError(error) } wait(for: [sendExpectation], timeout: 11) guard pendingEntity != nil else { XCTFail("no pending transaction after sending") try coordinator.stop() return } /** 3. getIncomingTransaction */ guard let incomingTx = try coordinator.getIncomingTransactions()?.first else { XCTFail("no incoming transaction") try coordinator.stop() return } let sentTxHeight: BlockHeight = 663189 /* 4. stage transaction at sentTxHeight */ try coordinator.stageBlockCreate(height: sentTxHeight) try coordinator.stageTransaction(incomingTx, at: sentTxHeight) /* 5. applyHeight(sentTxHeight) */ try coordinator.applyStaged(blockheight: sentTxHeight) sleep(2) /* 6. sync to latest height */ let secondSyncExpectation = XCTestExpectation(description: "after send expectation") try await withCheckedThrowingContinuation { continuation in do { try coordinator.sync(completion: { synchronizer in secondSyncExpectation.fulfill() continuation.resume() }, error: self.handleError) } catch { continuation.resume(throwing: error) } } wait(for: [secondSyncExpectation], timeout: 5) XCTAssertEqual(coordinator.synchronizer.pendingTransactions.count, 1) guard let afterStagePendingTx = coordinator.synchronizer.pendingTransactions.first else { return } /* 6a. verify that there's a pending transaction with a mined height of sentTxHeight */ XCTAssertEqual(afterStagePendingTx.minedHeight, sentTxHeight) /* 7. stage 20 blocks from sentTxHeight */ try coordinator.stageBlockCreate(height: sentTxHeight, count: 25) /* 7a. stage sent tx to sentTxHeight + 2 */ try coordinator.stageTransaction(incomingTx, at: sentTxHeight + 2) /* 8. applyHeight(sentTxHeight + 1) to cause a 1 block reorg */ try coordinator.applyStaged(blockheight: sentTxHeight + 1) sleep(2) /* 9. sync to latest height */ self.expectedReorgHeight = sentTxHeight + 1 let afterReorgExpectation = XCTestExpectation(description: "after reorg sync") try await withCheckedThrowingContinuation { continuation in do { try coordinator.sync(completion: { synchronizer in afterReorgExpectation.fulfill() continuation.resume() }, error: self.handleError) } catch { continuation.resume(throwing: error) } } wait(for: [reorgExpectation, afterReorgExpectation], timeout: 5) /* 10. verify that there's a pending transaction with -1 mined height */ guard let newPendingTx = coordinator.synchronizer.pendingTransactions.first else { XCTFail("No pending transaction") try coordinator.stop() return } XCTAssertEqual(newPendingTx.minedHeight, BlockHeight.empty()) /* 11. applyHeight(sentTxHeight + 2) */ try coordinator.applyStaged(blockheight: sentTxHeight + 2) sleep(2) let yetAnotherExpectation = XCTestExpectation(description: "after staging expectation") /* 11a. sync to latest height */ try await withCheckedThrowingContinuation { continuation in do { try coordinator.sync(completion: { synchronizer in yetAnotherExpectation.fulfill() continuation.resume() }, error: self.handleError) } catch { continuation.resume(throwing: error) } } wait(for: [yetAnotherExpectation], timeout: 5) /* 12. verify that there's a pending transaction with a mined height of sentTxHeight + 2 */ XCTAssertEqual(coordinator.synchronizer.pendingTransactions.count, 1) guard let newlyPendingTx = try coordinator.synchronizer.allPendingTransactions().first else { XCTFail("no pending transaction") try coordinator.stop() return } XCTAssertEqual(newlyPendingTx.minedHeight, sentTxHeight + 2) /* 13. apply height(sentTxHeight + 25) */ try coordinator.applyStaged(blockheight: sentTxHeight + 25) sleep(2) let thisIsTheLastExpectationIPromess = XCTestExpectation(description: "last sync") /* 14. sync to latest height */ try await withCheckedThrowingContinuation { continuation in do { try coordinator.sync(completion: { synchronizer in thisIsTheLastExpectationIPromess.fulfill() continuation.resume() }, error: self.handleError) } catch { continuation.resume(throwing: error) } } wait(for: [thisIsTheLastExpectationIPromess], timeout: 5) /* 15. verify that there's no pending transaction and that the tx is displayed on the sentTransactions collection */ XCTAssertEqual(coordinator.synchronizer.pendingTransactions.count, 0) XCTAssertNotNil( coordinator.synchronizer.sentTransactions .first( where: { transaction in guard let txId = transaction.rawTransactionId else { return false } return txId == newlyPendingTx.rawTransactionId } ), "Sent Tx is not on sent transactions" ) XCTAssertEqual( initialTotalBalance - newlyPendingTx.value - Zatoshi(1000), coordinator.synchronizer.initializer.getBalance() ) XCTAssertEqual( initialTotalBalance - newlyPendingTx.value - Zatoshi(1000), coordinator.synchronizer.initializer.getVerifiedBalance() ) } /// Uses the zcash-hackworks data set. /// A Re Org occurs at 663195, and sweeps an Inbound Tx that appears later on the chain. /// Steps: /// 1. reset dlwd /// 2. load blocks from txHeightReOrgBefore /// 3. applyStaged(663195) /// 4. sync to latest height /// 5. get balances /// 6. load blocks from dataset txHeightReOrgBefore /// 7. apply stage 663200 /// 8. sync to latest height /// 9. verify that the balance is equal to the one before the reorg func testReOrgChangesInboundMinedHeight() throws { try coordinator.reset(saplingActivation: 663150, branchID: branchID, chainName: chainName) sleep(2) try coordinator.resetBlocks(dataset: .predefined(dataset: .txHeightReOrgBefore)) sleep(2) try coordinator.applyStaged(blockheight: 663195) sleep(2) let firstSyncExpectation = XCTestExpectation(description: "first sync") try coordinator.sync(completion: { _ in firstSyncExpectation.fulfill() }, error: self.handleError) wait(for: [firstSyncExpectation], timeout: 5) let initialBalance: Zatoshi = coordinator.synchronizer.initializer.getBalance() let initialVerifiedBalance: Zatoshi = coordinator.synchronizer.initializer.getVerifiedBalance() guard let initialTxHeight = try coordinator.synchronizer.allReceivedTransactions().first?.minedHeight else { XCTFail("no incoming transaction found!") return } try coordinator.resetBlocks(dataset: .predefined(dataset: .txHeightReOrgAfter)) sleep(5) try coordinator.applyStaged(blockheight: 663200) sleep(6) let afterReOrgExpectation = XCTestExpectation(description: "after reorg") try coordinator.sync(completion: { _ in afterReOrgExpectation.fulfill() }, error: self.handleError) wait(for: [afterReOrgExpectation], timeout: 5) guard let afterReOrgTxHeight = coordinator.synchronizer.receivedTransactions.first?.minedHeight else { XCTFail("no incoming transaction found after re org!") return } XCTAssertEqual(initialVerifiedBalance, coordinator.synchronizer.initializer.getVerifiedBalance()) XCTAssertEqual(initialBalance, coordinator.synchronizer.initializer.getBalance()) XCTAssert(afterReOrgTxHeight > initialTxHeight) } /// Re Org removes incoming transaction and is never mined /// Steps: /// 1. sync prior to incomingTxHeight - 1 to get balances there /// 2. sync to latest height /// 3. cause reorg /// 4. sync to latest height /// 5. verify that reorg Happened at reorgHeight /// 6. verify that balances match initial balances func testReOrgRemovesIncomingTxForever() throws { hookToReOrgNotification() try coordinator.reset(saplingActivation: 663150, branchID: branchID, chainName: chainName) try coordinator.resetBlocks(dataset: .predefined(dataset: .txReOrgRemovesInboundTxBefore)) let reorgHeight: BlockHeight = 663195 self.expectedReorgHeight = reorgHeight self.expectedRewindHeight = reorgHeight - 10 try coordinator.applyStaged(blockheight: reorgHeight - 1) sleep(2) let firstSyncExpectation = XCTestExpectation(description: "first sync") /** 1. sync prior to incomingTxHeight - 1 to get balances there */ try coordinator.sync(completion: { _ in firstSyncExpectation.fulfill() }, error: self.handleError) wait(for: [firstSyncExpectation], timeout: 5) let initialTotalBalance: Zatoshi = coordinator.synchronizer.initializer.getBalance() let initialVerifiedBalance: Zatoshi = coordinator.synchronizer.initializer.getVerifiedBalance() try coordinator.applyStaged(blockheight: reorgHeight) let secondSyncExpectation = XCTestExpectation(description: "second sync expectation") /** 2. sync to latest height */ try coordinator.sync(completion: { _ in secondSyncExpectation.fulfill() }, error: self.handleError) wait(for: [secondSyncExpectation], timeout: 10) /** 3. cause reorg */ try coordinator.resetBlocks(dataset: .predefined(dataset: .txReOrgRemovesInboundTxAfter)) try coordinator.applyStaged(blockheight: 663200) sleep(2) let afterReorgSyncExpectation = XCTestExpectation(description: "after reorg expectation") try coordinator.sync(completion: { _ in afterReorgSyncExpectation.fulfill() }, error: self.handleError) wait(for: [afterReorgSyncExpectation], timeout: 5) XCTAssertEqual(initialVerifiedBalance, coordinator.synchronizer.initializer.getVerifiedBalance()) XCTAssertEqual(initialTotalBalance, coordinator.synchronizer.initializer.getBalance()) } /// Transaction was included in a block, and then is not included in a block after a reorg, and expires. /// Steps: /// 1. create fake chain /// 1a. sync to latest height /// 2. send transaction to recipient address /// 3. getIncomingTransaction /// 4. stage transaction at sentTxHeight /// 5. applyHeight(sentTxHeight) /// 6. sync to latest height /// 6a. verify that there's a pending transaction with a mined height of sentTxHeight /// 7. stage 15 blocks from sentTxHeigth to cause a reorg /// 8. sync to latest height /// 9. verify that there's an expired transaction as a pending transaction func testReOrgRemovesOutboundTxAndIsNeverMined() async throws { hookToReOrgNotification() /* 1. create fake chain */ try FakeChainBuilder.buildChain(darksideWallet: coordinator.service, branchID: branchID, chainName: chainName) let sentTxHeight: BlockHeight = 663195 try coordinator.applyStaged(blockheight: sentTxHeight - 1) sleep(2) let firstSyncExpectation = XCTestExpectation(description: "first sync") /* 1a. sync to latest height */ try await withCheckedThrowingContinuation { continuation in do { try coordinator.sync(completion: { synchronizer in continuation.resume() firstSyncExpectation.fulfill() }, error: self.handleError) } catch { continuation.resume(throwing: error) } } wait(for: [firstSyncExpectation], timeout: 10) sleep(1) let initialTotalBalance: Zatoshi = coordinator.synchronizer.initializer.getBalance() let sendExpectation = XCTestExpectation(description: "send expectation") var pendingEntity: PendingTransactionEntity? /* 2. send transaction to recipient address */ do { let pendingTx = try await coordinator.synchronizer.sendToAddress( spendingKey: self.coordinator.spendingKeys!.first!, zatoshi: Zatoshi(20000), toAddress: try Recipient(testRecipientAddress, network: self.network.networkType), memo: try! Memo(string: "this is a test") ) pendingEntity = pendingTx sendExpectation.fulfill() } catch { self.handleError(error) } wait(for: [sendExpectation], timeout: 11) guard pendingEntity != nil else { XCTFail("no pending transaction after sending") try coordinator.stop() return } /** 3. getIncomingTransaction */ guard let incomingTx = try coordinator.getIncomingTransactions()?.first else { XCTFail("no incoming transaction") try coordinator.stop() return } self.expectedReorgHeight = sentTxHeight + 1 /* 4. stage transaction at sentTxHeight */ try coordinator.stageBlockCreate(height: sentTxHeight) try coordinator.stageTransaction(incomingTx, at: sentTxHeight) /* 5. applyHeight(sentTxHeight) */ try coordinator.applyStaged(blockheight: sentTxHeight) sleep(2) /* 6. sync to latest height */ let secondSyncExpectation = XCTestExpectation(description: "after send expectation") try await withCheckedThrowingContinuation { continuation in do { try coordinator.sync(completion: { synchronizer in secondSyncExpectation.fulfill() continuation.resume() }, error: self.handleError) } catch { continuation.resume(throwing: error) } } wait(for: [secondSyncExpectation], timeout: 5) let extraBlocks = 25 try coordinator.stageBlockCreate(height: sentTxHeight, count: extraBlocks, nonce: 5) try coordinator.applyStaged(blockheight: sentTxHeight + 5) sleep(2) let reorgSyncExpectation = XCTestExpectation(description: "reorg sync expectation") try await withCheckedThrowingContinuation { continuation in do { try coordinator.sync(completion: { synchronizer in reorgSyncExpectation.fulfill() continuation.resume() }, error: self.handleError) } catch { continuation.resume(throwing: error) } } wait(for: [reorgExpectation, reorgSyncExpectation], timeout: 5) guard let pendingTx = coordinator.synchronizer.pendingTransactions.first else { XCTFail("no pending transaction after reorg sync") return } XCTAssertFalse(pendingTx.isMined) LoggerProxy.info("applyStaged(blockheight: \(sentTxHeight + extraBlocks - 1))") try coordinator.applyStaged(blockheight: sentTxHeight + extraBlocks - 1) sleep(2) let lastSyncExpectation = XCTestExpectation(description: "last sync expectation") try await withCheckedThrowingContinuation { continuation in do { try coordinator.sync(completion: { synchronizer in lastSyncExpectation.fulfill() continuation.resume() }, error: self.handleError) } catch { continuation.resume(throwing: error) } } wait(for: [lastSyncExpectation], timeout: 5) XCTAssertEqual(coordinator.synchronizer.initializer.getBalance(), initialTotalBalance) } func testLongSync() async throws { hookToReOrgNotification() /* 1. create fake chain */ let fullSyncLength = 100_000 try FakeChainBuilder.buildChain(darksideWallet: coordinator.service, branchID: branchID, chainName: chainName, length: fullSyncLength) try coordinator.applyStaged(blockheight: birthday + fullSyncLength) sleep(10) let firstSyncExpectation = XCTestExpectation(description: "first sync") /* sync to latest height */ try await withCheckedThrowingContinuation { continuation in do { try coordinator.sync( completion: { _ in firstSyncExpectation.fulfill() continuation.resume() }, error: { error in _ = try? self.coordinator.stop() firstSyncExpectation.fulfill() guard let testError = error else { XCTFail("failed with nil error") return } XCTFail("Failed with error: \(testError)") } ) } catch { continuation.resume(throwing: error) } } wait(for: [firstSyncExpectation], timeout: 500) let latestDownloadedHeight = try await coordinator.synchronizer.latestDownloadedHeight() XCTAssertEqual(latestDownloadedHeight, birthday + fullSyncLength) } func handleError(_ error: Error?) { _ = try? coordinator.stop() guard let testError = error else { XCTFail("failed with nil error") return } XCTFail("Failed with error: \(testError)") } func hookToReOrgNotification() { NotificationCenter.default.addObserver(self, selector: #selector(handleReorg(_:)), name: .blockProcessorHandledReOrg, object: nil) } }