diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1ee587..0a6060f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,53 @@ jobs: working-directory: bdk_demo run: flutter test + integration-tests: + runs-on: ubuntu-latest + services: + regtest: + image: ghcr.io/thunderbiscuit/podman-regtest-infinity-pro:0.4.0 + ports: + - 18443:18443 + - 18444:18444 + - 3002:3002 + - 60401:60401 + options: >- + --health-cmd "test $(bitcoin-cli -regtest -rpcuser=regtest -rpcpassword=password getblockcount) -gt 100" + --health-interval 3s + --health-timeout 5s + --health-retries 20 + env: + BDK_DART_RUN_INTEGRATION: "1" + BDK_DART_ELECTRUM_URL: "tcp://127.0.0.1:60401" + BDK_DART_ESPLORA_URL: "http://127.0.0.1:3002" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Setup Dart + uses: dart-lang/setup-dart@v1 + with: + sdk: "3.10.0" + + - name: Generate bindings and native library + run: | + chmod +x scripts/generate_bindings.sh + ./scripts/generate_bindings.sh + + - name: Pub get + run: dart pub get + + - name: Run integration tests + run: dart test --tags regtest + windows-smoke: runs-on: windows-latest @@ -154,7 +201,8 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v3 with: - packages: "" + packages: 'platform-tools' + - name: Setup Dart uses: dart-lang/setup-dart@v1 diff --git a/dart_test.yaml b/dart_test.yaml new file mode 100644 index 0000000..fef4f89 --- /dev/null +++ b/dart_test.yaml @@ -0,0 +1,3 @@ +tags: + integration: + regtest: diff --git a/test/integration/electrum_full_scan_test.dart b/test/integration/electrum_full_scan_test.dart new file mode 100644 index 0000000..6ea247f --- /dev/null +++ b/test/integration/electrum_full_scan_test.dart @@ -0,0 +1,269 @@ +@Tags(['integration', 'regtest']) +import 'dart:async'; +import 'dart:io'; + +import 'package:bdk_dart/bdk.dart'; +import 'package:test/test.dart'; + +import '../test_constants.dart'; +import 'integration_helpers.dart'; +import 'regtest_funding_helper.dart'; + +Future _deleteDirectoryWithRetry(Directory dir) async { + for (var attempt = 0; attempt < 10; attempt++) { + try { + if (dir.existsSync()) await dir.delete(recursive: true); + return; + } on PathAccessException { + await Future.delayed(const Duration(milliseconds: 100)); + } + } + if (dir.existsSync()) await dir.delete(recursive: true); +} + +String _createTempSqlitePath() { + final tempDir = Directory.systemTemp.createTempSync( + 'bdk_dart_electrum_fullscan_', + ); + addTearDown(() => _deleteDirectoryWithRetry(tempDir)); + return '${tempDir.path}/wallet.sqlite'; +} + +void main() { + group('Electrum fullScan integration', () { + test( + 'fund wallet and fullScan successfully', + () async { + final disposers = []; + final sqlitePath = _createTempSqlitePath(); + + try { + final descriptor = buildBip84Descriptor(Network.regtest); + addDisposer(disposers, descriptor.dispose); + + final changeDescriptor = buildBip84ChangeDescriptor(Network.regtest); + addDisposer(disposers, changeDescriptor.dispose); + + final persister = Persister.newSqlite(path: sqlitePath); + addDisposer(disposers, persister.dispose); + + final wallet = Wallet( + descriptor: descriptor, + changeDescriptor: changeDescriptor, + network: Network.regtest, + persister: persister, + lookahead: defaultLookahead, + ); + addDisposer(disposers, wallet.dispose); + + final revealed = wallet.revealNextAddress( + keychain: KeychainKind.external_, + ); + addDisposer(disposers, revealed.address.dispose); + wallet.persist(persister: persister); + + final client = buildElectrumClientFromEnv(); + addDisposer(disposers, client.dispose); + + final addressString = revealed.address.toString(); + printOnFailure('Funding Regtest address: $addressString'); + + final txid = await broadcastRegtestAddress( + addressString, + amount: 1.0, + ); + printOnFailure('Funding transaction ID: $txid'); + + // Phase 1: Scan while the funding transaction is still unconfirmed. + TxUnconfirmedWalletEvent? unconfirmedEvent; + var unconfirmedPositionSeen = false; + Object? lastUnconfirmedError; + + for (var attempt = 0; attempt < 15; attempt++) { + try { + final requestBuilder = wallet.startFullScan(); + addDisposer(disposers, requestBuilder.dispose); + + final request = requestBuilder.build(); + addDisposer(disposers, request.dispose); + + final update = client.fullScan( + request: request, + stopGap: 20, + batchSize: 100, + fetchPrevTxouts: true, + ); + addDisposer(disposers, update.dispose); + + final events = wallet.applyUpdateEvents(update: update); + + for (final event + in events.whereType()) { + if (event.txid.toString() == txid) { + unconfirmedEvent = event; + } + } + + final details = wallet.txDetails( + txid: Txid.fromString(hex: txid), + ); + + if (details != null && + details.chainPosition is UnconfirmedChainPosition) { + unconfirmedPositionSeen = true; + } + + if (unconfirmedEvent != null && unconfirmedPositionSeen) { + break; + } + } catch (error) { + lastUnconfirmedError = error; + printOnFailure( + 'Electrum unconfirmed scan attempt ${attempt + 1} failed: $error', + ); + } + + if (attempt < 14) { + await Future.delayed(const Duration(seconds: 1)); + } + } + + // Assert that the Dart API exposed the unconfirmed transaction state. + expect( + unconfirmedEvent, + isNotNull, + reason: + 'The funded transaction should produce a TxUnconfirmedWalletEvent. ' + 'Last error: $lastUnconfirmedError', + ); + + final pendingEvent = unconfirmedEvent!; + + expect(pendingEvent.tx.computeTxid().toString(), equals(txid)); + expect( + pendingEvent.oldBlockTime, + isNull, + reason: 'The transaction is first observed in the mempool.', + ); + + expect( + unconfirmedPositionSeen, + isTrue, + reason: + 'The wallet should expose UnconfirmedChainPosition before mining.', + ); + + final pendingDetails = wallet.txDetails( + txid: Txid.fromString(hex: txid), + ); + + expect(pendingDetails, isNotNull); + expect( + pendingDetails!.chainPosition, + isA(), + ); + + // Phase 2: Confirm the transaction, then run the existing confirmed scan loop. + await mineRegtestBlocks(); + + var balance = wallet.balance().total; + Object? lastError; + TxConfirmedWalletEvent? confirmedEvent; + + for (var attempt = 0; attempt < 15; attempt++) { + try { + final requestBuilder = wallet.startFullScan(); + addDisposer(disposers, requestBuilder.dispose); + + final request = requestBuilder.build(); + addDisposer(disposers, request.dispose); + + final update = client.fullScan( + request: request, + stopGap: 20, + batchSize: 100, + fetchPrevTxouts: true, + ); + addDisposer(disposers, update.dispose); + + final events = wallet.applyUpdateEvents(update: update); + + for (final event in events.whereType()) { + if (event.txid.toString() == txid) { + confirmedEvent = event; + } + } + + balance = wallet.balance().total; + + if (confirmedEvent != null && balance.toSat() > 0) { + break; + } + } catch (error) { + lastError = error; + printOnFailure( + 'Electrum fullScan attempt ${attempt + 1} failed: $error', + ); + } + + if (confirmedEvent == null && attempt < 14) { + await Future.delayed(const Duration(seconds: 1)); + } + } + + wallet.persist(persister: persister); + + printOnFailure('Final Wallet Balance: ${balance.toSat()} sats'); + + expect( + balance.toSat(), + greaterThan(0), + reason: + 'Wallet should have a positive balance after funding. ' + 'Last error: $lastError', + ); + + expect( + confirmedEvent, + isNotNull, + reason: + 'The funded transaction should produce a confirmed wallet event.', + ); + + final event = confirmedEvent!; + + expect(event.tx.computeTxid().toString(), equals(txid)); + expect(event.blockTime.blockId.height, greaterThan(0)); + expect(event.blockTime.blockId.hash.toString(), isNotEmpty); + expect(event.blockTime.confirmationTime, greaterThan(0)); + + final details = wallet.txDetails(txid: Txid.fromString(hex: txid)); + + expect(details, isNotNull); + expect(details!.txid.toString(), equals(txid)); + expect(details.tx.computeTxid().toString(), equals(txid)); + expect(details.chainPosition, isA()); + + final position = details.chainPosition as ConfirmedChainPosition; + + expect( + position.confirmationBlockTime.blockId.height, + equals(event.blockTime.blockId.height), + ); + expect( + position.confirmationBlockTime.blockId.hash.toString(), + equals(event.blockTime.blockId.hash.toString()), + ); + expect( + position.confirmationBlockTime.confirmationTime, + equals(event.blockTime.confirmationTime), + ); + } finally { + disposeAll(disposers); + } + }, + skip: integrationSkipReason(requiredEnv: [electrumUrlEnv]), + timeout: const Timeout(Duration(minutes: 2)), + ); + }); +} diff --git a/test/integration/esplora_full_scan_test.dart b/test/integration/esplora_full_scan_test.dart new file mode 100644 index 0000000..3a512f7 --- /dev/null +++ b/test/integration/esplora_full_scan_test.dart @@ -0,0 +1,298 @@ +@Tags(['integration', 'regtest']) +import 'dart:async'; +import 'dart:io'; + +import 'package:bdk_dart/bdk.dart'; +import 'package:test/test.dart'; + +import '../test_constants.dart'; +import 'integration_helpers.dart'; +import 'regtest_funding_helper.dart'; + +Future _deleteDirectoryWithRetry(Directory dir) async { + for (var attempt = 0; attempt < 10; attempt++) { + try { + if (dir.existsSync()) await dir.delete(recursive: true); + return; + } on PathAccessException { + await Future.delayed(const Duration(milliseconds: 100)); + } + } + if (dir.existsSync()) await dir.delete(recursive: true); +} + +String _createTempSqlitePath() { + final tempDir = Directory.systemTemp.createTempSync( + 'bdk_dart_esplora_fullscan_', + ); + addTearDown(() => _deleteDirectoryWithRetry(tempDir)); + return '${tempDir.path}/wallet.sqlite'; +} + +void main() { + group('Esplora fullScan integration', () { + test( + 'fund wallet and fullScan successfully', + () async { + final disposers = []; + final sqlitePath = _createTempSqlitePath(); + + try { + final descriptor = buildBip84Descriptor(Network.regtest); + addDisposer(disposers, descriptor.dispose); + + final changeDescriptor = buildBip84ChangeDescriptor(Network.regtest); + addDisposer(disposers, changeDescriptor.dispose); + + final persister = Persister.newSqlite(path: sqlitePath); + addDisposer(disposers, persister.dispose); + + final wallet = Wallet( + descriptor: descriptor, + changeDescriptor: changeDescriptor, + network: Network.regtest, + persister: persister, + lookahead: defaultLookahead, + ); + addDisposer(disposers, wallet.dispose); + + final revealed = wallet.revealNextAddress( + keychain: KeychainKind.external_, + ); + addDisposer(disposers, revealed.address.dispose); + wallet.persist(persister: persister); + + final client = buildEsploraClientFromEnv(); + // note: esplora client doesn't need explicit dispose + + final addressString = revealed.address.toString(); + printOnFailure('Funding Regtest address: $addressString'); + + final txid = await broadcastRegtestAddress( + addressString, + amount: 1.0, + ); + printOnFailure('Funding transaction ID: $txid'); + + // Deterministic wait: Poll Esplora for the transaction in mempool + Object? lastError; + var txFound = false; + + for (var attempt = 0; attempt < 15; attempt++) { + try { + final tx = client.getTx(txid: Txid.fromString(hex: txid)); + if (tx != null) { + txFound = true; + break; + } + } catch (error) { + lastError = error; + printOnFailure( + 'Esplora getTx attempt ${attempt + 1} failed: $error', + ); + } + + if (!txFound && attempt < 14) { + await Future.delayed(const Duration(seconds: 1)); + } + } + + expect( + txFound, + isTrue, + reason: + 'Esplora should index the funding transaction within the timeout. ' + 'Last error: $lastError', + ); + + // Phase 1: Scan while the transaction is unconfirmed. + TxUnconfirmedWalletEvent? unconfirmedEvent; + var unconfirmedPositionSeen = false; + Object? lastUnconfirmedError; + + for (var attempt = 0; attempt < 15; attempt++) { + try { + final requestBuilder = wallet.startFullScan(); + addDisposer(disposers, requestBuilder.dispose); + + final request = requestBuilder.build(); + addDisposer(disposers, request.dispose); + + final update = client.fullScan( + request: request, + stopGap: 20, + parallelRequests: 1, + ); + addDisposer(disposers, update.dispose); + + final events = wallet.applyUpdateEvents(update: update); + + for (final event + in events.whereType()) { + if (event.txid.toString() == txid) { + unconfirmedEvent = event; + } + } + + final details = wallet.txDetails( + txid: Txid.fromString(hex: txid), + ); + + if (details != null && + details.chainPosition is UnconfirmedChainPosition) { + unconfirmedPositionSeen = true; + } + + if (unconfirmedEvent != null && unconfirmedPositionSeen) { + break; + } + } catch (error) { + lastUnconfirmedError = error; + printOnFailure( + 'Esplora unconfirmed scan attempt ${attempt + 1} failed: $error', + ); + } + + if (attempt < 14) { + await Future.delayed(const Duration(seconds: 1)); + } + } + + expect( + unconfirmedEvent, + isNotNull, + reason: + 'The funded transaction should produce a TxUnconfirmedWalletEvent. ' + 'Last error: $lastUnconfirmedError', + ); + + final pendingEvent = unconfirmedEvent!; + + expect(pendingEvent.tx.computeTxid().toString(), equals(txid)); + expect( + pendingEvent.oldBlockTime, + isNull, + reason: 'The transaction is first observed in the mempool.', + ); + + expect( + unconfirmedPositionSeen, + isTrue, + reason: + 'The wallet should expose UnconfirmedChainPosition before mining.', + ); + + final pendingDetails = wallet.txDetails( + txid: Txid.fromString(hex: txid), + ); + + expect(pendingDetails, isNotNull); + expect( + pendingDetails!.chainPosition, + isA(), + ); + + // Phase 2: Mine the transaction before running the confirmed scan. + await mineRegtestBlocks(); + + // Phase 3: Run the confirmed scan loop until confirmedEvent is captured + var balance = wallet.balance().total; + TxConfirmedWalletEvent? confirmedEvent; + Object? lastConfirmedError; + + for (var attempt = 0; attempt < 15; attempt++) { + try { + final requestBuilder = wallet.startFullScan(); + addDisposer(disposers, requestBuilder.dispose); + + final request = requestBuilder.build(); + addDisposer(disposers, request.dispose); + + final update = client.fullScan( + request: request, + stopGap: 20, + parallelRequests: 1, + ); + addDisposer(disposers, update.dispose); + + final events = wallet.applyUpdateEvents(update: update); + + for (final event in events.whereType()) { + if (event.txid.toString() == txid) { + confirmedEvent = event; + } + } + + balance = wallet.balance().total; + + if (confirmedEvent != null && balance.toSat() > 0) { + break; + } + } catch (error) { + lastConfirmedError = error; + printOnFailure( + 'Esplora confirmed scan attempt ${attempt + 1} failed: $error', + ); + } + + if (confirmedEvent == null && attempt < 14) { + await Future.delayed(const Duration(seconds: 1)); + } + } + + wallet.persist(persister: persister); + + printOnFailure('Final Wallet Balance: ${balance.toSat()} sats'); + + expect( + balance.toSat(), + greaterThan(0), + reason: + 'Wallet should have a positive balance after funding. ' + 'Last error: $lastConfirmedError', + ); + + expect( + confirmedEvent, + isNotNull, + reason: + 'The funded transaction should produce a confirmed wallet event.', + ); + + final event = confirmedEvent!; + + expect(event.tx.computeTxid().toString(), equals(txid)); + expect(event.blockTime.blockId.height, greaterThan(0)); + expect(event.blockTime.blockId.hash.toString(), isNotEmpty); + expect(event.blockTime.confirmationTime, greaterThan(0)); + + final details = wallet.txDetails(txid: Txid.fromString(hex: txid)); + + expect(details, isNotNull); + expect(details!.txid.toString(), equals(txid)); + expect(details.tx.computeTxid().toString(), equals(txid)); + expect(details.chainPosition, isA()); + + final position = details.chainPosition as ConfirmedChainPosition; + + expect( + position.confirmationBlockTime.blockId.height, + equals(event.blockTime.blockId.height), + ); + expect( + position.confirmationBlockTime.blockId.hash.toString(), + equals(event.blockTime.blockId.hash.toString()), + ); + expect( + position.confirmationBlockTime.confirmationTime, + equals(event.blockTime.confirmationTime), + ); + } finally { + disposeAll(disposers); + } + }, + skip: integrationSkipReason(requiredEnv: [esploraUrlEnv]), + timeout: const Timeout(Duration(minutes: 2)), + ); + }); +} diff --git a/test/integration/regtest_funding_helper.dart b/test/integration/regtest_funding_helper.dart new file mode 100644 index 0000000..8555248 --- /dev/null +++ b/test/integration/regtest_funding_helper.dart @@ -0,0 +1,91 @@ +import 'dart:convert'; +import 'dart:io'; + +String _envOrDefault(String key, String defaultValue) { + final value = Platform.environment[key]; + return (value != null && value.trim().isNotEmpty) + ? value.trim() + : defaultValue; +} + +/// Makes a JSON-RPC call to the local Bitcoin Core Regtest node. +Future _rpcCall(String method, [dynamic params = const []]) async { + // Note the /wallet/faucet path for wallet-specific commands + final rpcUrl = _envOrDefault( + 'BDK_DART_BITCOIN_RPC_URL', + 'http://127.0.0.1:18443/wallet/faucet', + ); + final rpcUser = _envOrDefault('BDK_DART_BITCOIN_RPC_USER', 'regtest'); + final rpcPassword = _envOrDefault( + 'BDK_DART_BITCOIN_RPC_PASSWORD', + 'password', + ); + + final client = HttpClient(); + client.connectionTimeout = const Duration(seconds: 10); + + try { + final request = await client + .postUrl(Uri.parse(rpcUrl)) + .timeout(const Duration(seconds: 10)); + + final auth = base64Encode(utf8.encode('$rpcUser:$rpcPassword')); + request.headers.set(HttpHeaders.authorizationHeader, 'Basic $auth'); + request.headers.contentType = ContentType.json; + + final body = jsonEncode({ + 'jsonrpc': '1.0', + 'id': 'bdk_dart_integration', + 'method': method, + 'params': params, + }); + + request.write(body); + + final response = await request.close().timeout(const Duration(seconds: 10)); + final responseBody = await response.transform(utf8.decoder).join(); + + if (response.statusCode != HttpStatus.ok) { + throw Exception('RPC Error (HTTP ${response.statusCode}): $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + if (decoded['error'] != null) { + throw Exception('RPC JSON Error: ${decoded['error']}'); + } + + return decoded['result']; + } finally { + client.close(); + } +} + +/// Broadcasts a transaction to a Regtest address without mining it. +/// +/// Returns the transaction ID. +Future broadcastRegtestAddress( + String address, { + double amount = 1.0, +}) async { + return await _rpcCall('sendtoaddress', { + 'address': address, + 'amount': amount, + 'fee_rate': 1.0, + }) + as String; +} + +/// Mines [blocks] Regtest blocks. +Future mineRegtestBlocks({int blocks = 2}) async { + final minerAddress = await _rpcCall('getnewaddress') as String; + await _rpcCall('generatetoaddress', [blocks, minerAddress]); +} + +/// Funds a Regtest address and mines 2 blocks to confirm the transaction. +/// +/// Returns the transaction ID. +Future fundRegtestAddress(String address, {double amount = 1.0}) async { + final txid = await broadcastRegtestAddress(address, amount: amount); + await mineRegtestBlocks(); + return txid; +}