diff --git a/.gitignore b/.gitignore index 3a85790..4dda46f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ # https://dart.dev/guides/libraries/private-files # Created by `dart pub` .dart_tool/ +build/ diff --git a/example/pubspec.lock b/example/pubspec.lock index 43379b9..9f2ed83 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -29,8 +29,8 @@ packages: dependency: "direct main" description: path: "." - ref: cake-update-v7 - resolved-ref: f577e83fe78766b2655ea0602baa9299b953a31b + ref: cake-update-v11 + resolved-ref: "4e41f96f4838139895c65c3f49109d05af5d46aa" url: "https://github.com/cake-tech/bitcoin_base" source: git version: "4.7.0" @@ -63,10 +63,10 @@ packages: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.1" cli_util: dependency: transitive description: @@ -79,34 +79,34 @@ packages: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" collection: dependency: transitive description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.1" convert: dependency: transitive description: name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" crypto: dependency: transitive description: name: crypto - sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -119,10 +119,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.3" ffi: dependency: transitive description: @@ -181,38 +181,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" js: dependency: transitive description: name: js - sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "0.7.2" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -233,26 +257,26 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.17.0" package_config: dependency: transitive description: @@ -265,10 +289,10 @@ packages: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" plugin_platform_interface: dependency: transitive description: @@ -292,12 +316,20 @@ packages: sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" sky_engine: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_span: dependency: transitive description: @@ -317,18 +349,18 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" string_scanner: dependency: transitive description: @@ -349,34 +381,42 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.10" typed_data: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.4.0" vector_math: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "1.1.1" yaml: dependency: transitive description: @@ -394,5 +434,5 @@ packages: source: hosted version: "2.2.1" sdks: - dart: ">=3.3.0 <4.0.0" - flutter: ">=3.3.0" + dart: ">=3.9.0-0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 9652a33..c42e3a1 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -45,7 +45,7 @@ dependencies: bitcoin_base: git: url: https://github.com/cake-tech/bitcoin_base - ref: cake-update-v7 + ref: cake-update-v11 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/lib/generated_bindings.dart b/lib/generated_bindings.dart index 409e9a7..2f1690d 100644 --- a/lib/generated_bindings.dart +++ b/lib/generated_bindings.dart @@ -1615,6 +1615,115 @@ class NativeLibrary { late final _api_scan_outputs = _api_scan_outputsPtr .asFunction Function(ffi.Pointer)>(); + /// Creates a session from a receiver config. Returns null on malformed input + /// or on internal panic — callers must null-check before use. + ffi.Pointer api_session_create( + ffi.Pointer config, + ) { + return _api_session_create( + config, + ); + } + + late final _api_session_createPtr = _lookup< + ffi.NativeFunction< + ffi.Pointer Function( + ffi.Pointer)>>('api_session_create'); + late final _api_session_create = _api_session_createPtr + .asFunction Function(ffi.Pointer)>(); + + /// Destroys a session created by `api_session_create`. Safe to call with null. + void api_session_destroy( + ffi.Pointer session, + ) { + return _api_session_destroy( + session, + ); + } + + late final _api_session_destroyPtr = + _lookup)>>( + 'api_session_destroy'); + late final _api_session_destroy = _api_session_destroyPtr + .asFunction)>(); + + /// Scans `outputs_data` against `tweak_bytes` using the given session. + /// Returns `"{}"` for the (overwhelmingly common) no-match case without a + /// serde round-trip, a JSON match map on a hit, or null on malformed input / + /// internal panic — callers must null-check before treating the result as a + /// string, and must still call `free_pointer` on any non-null result. + ffi.Pointer api_session_scan( + ffi.Pointer session, + ffi.Pointer> outputs_data, + int outputs_data_len, + ffi.Pointer tweak_bytes, + ) { + return _api_session_scan( + session, + outputs_data, + outputs_data_len, + tweak_bytes, + ); + } + + late final _api_session_scanPtr = _lookup< + ffi.NativeFunction< + ffi.Pointer Function( + ffi.Pointer, + ffi.Pointer>, + ffi.Uint64, + ffi.Pointer)>>('api_session_scan'); + late final _api_session_scan = _api_session_scanPtr.asFunction< + ffi.Pointer Function(ffi.Pointer, + ffi.Pointer>, int, ffi.Pointer)>(); + + /// Decodes+scans one v2 block record (`block_bytes`, already base64-decoded + /// by the caller) against `session`'s persistent receiver. Returns a JSON + /// array of match records `{height, txid, vout, label, output_pubkey, + /// tweak}` (txid in display-hex order, per the note above), `"[]"` for the + /// overwhelmingly common no-match case (no serde round-trip), or null on a + /// malformed block / internal panic — same untrusted-input contract as the + /// rest of the session API (ADR-0006). + ffi.Pointer api_session_scan_block_v2( + ffi.Pointer session, + ffi.Pointer block_bytes, + int block_bytes_len, + ) { + return _api_session_scan_block_v2( + session, + block_bytes, + block_bytes_len, + ); + } + + late final _api_session_scan_block_v2Ptr = _lookup< + ffi.NativeFunction< + ffi.Pointer Function( + ffi.Pointer, + ffi.Pointer, + ffi.Uint64)>>('api_session_scan_block_v2'); + late final _api_session_scan_block_v2 = + _api_session_scan_block_v2Ptr.asFunction< + ffi.Pointer Function( + ffi.Pointer, ffi.Pointer, int)>(); + + /// Highest `blockchain.tweaks.subscribe` wire-protocol version this build's + /// decoder understands. `1` = JSON only (`api_scan_outputs`/`api_session_scan`). + /// `2` = the compact binary protocol (`api_session_scan_block_v2`), per + /// electrs-tweaks's `doc/tweaks_v2_protocol.md`. Client-side capability + /// negotiation must take `min(server-advertised, this)`, never the server's + /// offer alone, so a client can't attempt a version its own decoder can't + /// read. + int api_max_wire_version() { + return _api_max_wire_version(); + } + + late final _api_max_wire_versionPtr = + _lookup>( + 'api_max_wire_version'); + late final _api_max_wire_version = + _api_max_wire_versionPtr.asFunction(); + void free_pointer( ffi.Pointer ptr, ) { @@ -1741,7 +1850,7 @@ final class __pthread_mutex_s extends ffi.Struct { external int __spins; @ffi.Short() - external int __elision; + external int __unused; external __pthread_list_t __list; } @@ -1773,11 +1882,8 @@ final class __pthread_rwlock_arch_t extends ffi.Struct { @ffi.Int() external int __shared; - @ffi.SignedChar() - external int __rwelision; - - @ffi.Array.multi([7]) - external ffi.Array __pad1; + @ffi.UnsignedLong() + external int __pad1; @ffi.UnsignedLong() external int __pad2; @@ -1791,9 +1897,6 @@ final class __pthread_cond_s extends ffi.Struct { external __atomic_wide_counter __g1_start; - @ffi.Array.multi([2]) - external ffi.Array __g_refs; - @ffi.Array.multi([2]) external ffi.Array __g_size; @@ -1805,6 +1908,12 @@ final class __pthread_cond_s extends ffi.Struct { @ffi.Array.multi([2]) external ffi.Array __g_signals; + + @ffi.UnsignedInt() + external int __unused_initialized_1; + + @ffi.UnsignedInt() + external int __unused_initialized_2; } final class __once_flag extends ffi.Struct { @@ -1934,6 +2043,8 @@ typedef __compar_fn_tFunction = ffi.Int Function( typedef Dart__compar_fn_tFunction = int Function( ffi.Pointer, ffi.Pointer); +final class SpSession extends ffi.Opaque {} + final class OutputData extends ffi.Struct { external ffi.Pointer pubkey_bytes; @@ -1990,6 +2101,8 @@ const int _FEATURES_H = 1; const int _DEFAULT_SOURCE = 1; +const int __GLIBC_USE_ISOC2Y = 1; + const int __GLIBC_USE_ISOC23 = 1; const int __USE_ISOC11 = 1; @@ -2000,7 +2113,7 @@ const int __USE_ISOC95 = 1; const int _POSIX_SOURCE = 1; -const int _POSIX_C_SOURCE = 200809; +const int _POSIX_C_SOURCE = 202405; const int __USE_POSIX = 1; @@ -2016,6 +2129,8 @@ const int __USE_XOPEN2K8 = 1; const int _ATFILE_SOURCE = 1; +const int __USE_XOPEN2K24 = 1; + const int __WORDSIZE = 64; const int __WORDSIZE_TIME64_COMPAT32 = 1; @@ -2042,7 +2157,7 @@ const int __GNU_LIBRARY__ = 6; const int __GLIBC__ = 2; -const int __GLIBC_MINOR__ = 40; +const int __GLIBC_MINOR__ = 43; const int _SYS_CDEFS_H = 1; @@ -2228,9 +2343,9 @@ const int __W_CONTINUED = 65535; const int __WCOREFLAG = 128; -const int __HAVE_FLOAT128 = 0; +const int __HAVE_FLOAT128 = 1; -const int __HAVE_DISTINCT_FLOAT128 = 0; +const int __HAVE_DISTINCT_FLOAT128 = 1; const int __HAVE_FLOAT64X = 1; @@ -2258,7 +2373,7 @@ const int __HAVE_DISTINCT_FLOAT64X = 0; const int __HAVE_DISTINCT_FLOAT128X = 0; -const int __HAVE_FLOAT128_UNLIKE_LDBL = 0; +const int __HAVE_FLOAT128_UNLIKE_LDBL = 1; const int __HAVE_FLOATN_NOT_TYPEDEF = 0; @@ -2356,8 +2471,6 @@ const int _THREAD_MUTEX_INTERNAL_H = 1; const int __PTHREAD_MUTEX_HAVE_PREV = 1; -const int __PTHREAD_RWLOCK_ELISION_EXTRA = 0; - const int __have_pthread_attr_t = 1; const int _ALLOCA_H = 1; diff --git a/lib/scanner.dart b/lib/scanner.dart index 1c5cc44..bb113ab 100644 --- a/lib/scanner.dart +++ b/lib/scanner.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:ffi'; import 'dart:io'; +import 'dart:typed_data'; import 'package:ffi/ffi.dart'; import 'package:sp_scanner/generated_bindings.dart'; import 'package:blockchain_utils/blockchain_utils.dart' show BytesUtils; @@ -167,3 +168,117 @@ Map scanOutputs( ) { return interpretBytesVec(callApiScanOutputs(outputsToCheck, tweakDataForRecipient, receiver)); } + +/// A persistent native scan session: the `Secp256k1` context, `Receiver`, +/// and labels are built once (in [ScanSession.create]) and reused across +/// every [scan] call, instead of being rebuilt on every call the way +/// [scanOutputs] does. Scan math and results are identical to [scanOutputs] +/// for the same inputs. +/// +/// Exactly one session per calling isolate — the underlying native state is +/// not safe to share across isolates/threads. +/// +/// There is no finalizer: callers own the native memory and MUST call +/// [dispose] exactly once when done with the session (e.g. before the +/// isolate holding it exits). A hard `Isolate.kill` will leak the native +/// session — the caller must cooperatively stop and dispose first. +class ScanSession { + final Pointer _session; + + ScanSession._(this._session); + + /// Creates a session for [receiver]. Throws [StateError] if the receiver + /// data is malformed (never expected for real wallet keys, but the native + /// side treats this as untrusted input rather than panicking). + factory ScanSession.create(Receiver receiver) { + final receiverData = createReceiverDataStruct( + receiver.bScan, + receiver.BSpend, + receiver.isTestnet, + receiver.labels, + receiver.labelsLen, + ); + + final session = lib.api_session_create(receiverData); + freeReceiverDataStruct(receiverData); + + if (session == nullptr) { + throw StateError('sp_scanner: api_session_create failed on the given receiver data'); + } + + return ScanSession._(session); + } + + /// Scans [outputsToCheck] (a list of single-element `[pubkeyHex]` lists, + /// matching [scanOutputs]'s shape) against [tweakDataForRecipient]. Returns + /// `{label: {pubkey: tweak}}`, or `{}` for no match. Throws [StateError] on + /// malformed input (e.g. an invalid tweak) rather than propagating a + /// native panic. + Map scan(List outputsToCheck, String tweakDataForRecipient) { + final pointers = calloc>(outputsToCheck.length); + for (int i = 0; i < outputsToCheck.length; i++) { + pointers[i] = createOutputDataStruct(outputsToCheck[i][0].toString()); + } + + final tweakBytes = BytesUtils.fromHexString(tweakDataForRecipient); + final tweakPtr = calloc(tweakBytes.length); + tweakPtr.asTypedList(tweakBytes.length).setAll(0, tweakBytes); + + final result = lib.api_session_scan(_session, pointers, outputsToCheck.length, tweakPtr); + + for (int i = 0; i < outputsToCheck.length; i++) { + freeOutputDataStruct(pointers[i]); + } + calloc.free(pointers); + calloc.free(tweakPtr); + + if (result == nullptr) { + throw StateError('sp_scanner: api_session_scan failed on the given tweak/output data'); + } + + return interpretBytesVec(result); + } + + /// Decodes+scans one `blockchain.tweaks.subscribe` v2 binary block record + /// against this session. [blockBytes] is the raw block bytes — the wire + /// blob is base64 (see electrs-tweaks's `doc/tweaks_v2_protocol.md`), so + /// callers must `base64Decode` it before calling this. One block per + /// server push notification, so one call here per notification (the + /// server never batches multiple blocks into one message). + /// + /// Returns a list of match records, each shaped `{height, txid, vout, + /// label, output_pubkey, tweak}` (`txid` already in conventional + /// display-hex order, not the wire's internal/consensus order — see the + /// txid byte-order note in the protocol doc). An empty list means no + /// match, the overwhelmingly common case. Throws [StateError] on a + /// malformed block (server bug, or a version mismatch — this must only + /// ever be called with bytes produced by a server that negotiated + /// `protocol_version: 2`) rather than propagating a native panic. + List scanBlock(Uint8List blockBytes) { + final blockPtr = calloc(blockBytes.length); + blockPtr.asTypedList(blockBytes.length).setAll(0, blockBytes); + + final result = lib.api_session_scan_block_v2(_session, blockPtr, blockBytes.length); + calloc.free(blockPtr); + + if (result == nullptr) { + throw StateError('sp_scanner: api_session_scan_block_v2 failed on the given block bytes'); + } + + final jsonString = result.cast().toDartString(); + freePointer(result); + return jsonDecode(jsonString) as List; + } + + /// Releases the native session. Must be called exactly once; the session + /// must not be used afterward. + void dispose() { + lib.api_session_destroy(_session); + } +} + +/// Highest `blockchain.tweaks.subscribe` wire-protocol version this build's +/// native decoder understands. Capability negotiation must use +/// `min(serverAdvertisedVersion, maxWireVersion())`, never the server's +/// advertised version alone. +int maxWireVersion() => lib.api_max_wire_version(); diff --git a/pubspec.lock b/pubspec.lock index 9597911..a0573a8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -25,12 +25,20 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + bip39: + dependency: "direct dev" + description: + name: bip39 + sha256: de1ee27ebe7d96b84bb3a04a4132a0a3007dcdd5ad27dd14aa87a29d97c45edc + url: "https://pub.dev" + source: hosted + version: "1.0.6" bitcoin_base: dependency: "direct main" description: path: "." - ref: cake-update-v7 - resolved-ref: f577e83fe78766b2655ea0602baa9299b953a31b + ref: e2d26adbe54df60159323c111ed0f6b3b8cde730 + resolved-ref: e2d26adbe54df60159323c111ed0f6b3b8cde730 url: "https://github.com/cake-tech/bitcoin_base" source: git version: "4.7.0" @@ -63,10 +71,10 @@ packages: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.1" cli_util: dependency: transitive description: @@ -79,42 +87,42 @@ packages: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" collection: dependency: transitive description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.1" convert: dependency: transitive description: name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" crypto: dependency: transitive description: name: crypto - sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.7" fake_async: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.3" ffi: dependency: "direct main" description: @@ -173,38 +181,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" js: dependency: transitive description: name: js - sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "0.7.2" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -225,26 +257,26 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.17.0" package_config: dependency: transitive description: @@ -257,10 +289,10 @@ packages: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" plugin_platform_interface: dependency: "direct main" description: @@ -281,15 +313,23 @@ packages: dependency: transitive description: name: quiver - sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + sha256: b1c1ac5ce6688d77f65f3375a9abb9319b3cb32486bdc7a1e0fdf004d7ba4e47 + url: "https://pub.dev" + source: hosted + version: "3.2.1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "0.28.0" sky_engine: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_span: dependency: transitive description: @@ -302,18 +342,18 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" string_scanner: dependency: transitive description: @@ -334,34 +374,42 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.10" typed_data: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.4.0" vector_math: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" yaml: dependency: transitive description: @@ -379,5 +427,5 @@ packages: source: hosted version: "2.2.1" sdks: - dart: ">=3.3.0 <4.0.0" - flutter: ">=3.3.0" + dart: ">=3.9.0-0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/pubspec.yaml b/pubspec.yaml index 443eced..a9614cd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,12 +20,13 @@ dependencies: bitcoin_base: git: url: https://github.com/cake-tech/bitcoin_base - ref: cake-update-v7 + ref: e2d26adbe54df60159323c111ed0f6b3b8cde730 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^2.0.0 + bip39: ^1.0.6 ffigen: output: 'lib/generated_bindings.dart' diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 84b0329..4357124 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -11,6 +11,7 @@ use secp256k1::{PublicKey, SecretKey, XOnlyPublicKey}; use silentpayments::{receiving::Label, utils::receiving::calculate_shared_secret}; use std::os::raw::c_char; +use std::panic::{catch_unwind, AssertUnwindSafe}; #[repr(C)] pub struct OutputData { @@ -123,6 +124,318 @@ pub extern "C" fn api_scan_outputs(data: *const ParamData) -> *mut i8 { ptr as *mut i8 } +// --- Persistent session API ------------------------------------------------- +// +// `api_scan_outputs` above rebuilds a `Secp256k1` context, `Receiver`, and all +// `Label`s on every call. The session API below builds that state once and +// reuses it across many `api_session_scan` calls from the same caller — one +// session per calling isolate/thread, since `Receiver`/`Secp256k1` are not +// `Send`/`Sync`. +// +// Unlike `api_scan_outputs`, every function here treats its input as +// untrusted (server-influenced) bytes: malformed input returns a null/error +// sentinel instead of panicking, and the whole body is wrapped in +// `catch_unwind` as a hard backstop, since a panic unwinding across this +// `extern "C"` boundary would otherwise be undefined behavior / abort the +// host process. `api_scan_outputs`'s existing panic-on-bad-input behavior is +// deliberately left as-is — hardening it is a separate, general follow-up, +// not part of this session-scoped addition. + +/// Persistent session: an interior-mutable `Receiver` (label state is only +/// mutated at creation) plus the scan key needed to recompute shared secrets. +pub struct SpSession { + receiver: Receiver, + b_scan: SecretKey, +} + +/// Creates a session from a receiver config. Returns null on malformed input +/// or on internal panic — callers must null-check before use. +#[no_mangle] +pub extern "C" fn api_session_create(config: *const ReceiverData) -> *mut SpSession { + if config.is_null() { + return std::ptr::null_mut(); + } + + let result = catch_unwind(AssertUnwindSafe(|| { + let config = unsafe { &*config }; + + let b_scan = SecretKey::from_slice(unsafe { + slice::from_raw_parts(config.b_scan_bytes, 32) + }) + .ok()?; + let B_spend = PublicKey::from_slice(unsafe { + slice::from_raw_parts(config.B_spend_bytes, 33) + }) + .ok()?; + + let secp = secp256k1::Secp256k1::new(); + let change_label = Label::new(b_scan, 0); + let mut receiver = Receiver::new( + 0, + b_scan.public_key(&secp), + B_spend, + change_label, + config.is_testnet, + ) + .ok()?; + + let labels = unsafe { slice::from_raw_parts(config.labels, config.labels_len as usize) }; + for label_int in labels { + let label = Label::new(b_scan, *label_int); + receiver.add_label(label).ok()?; + } + + Some(Box::into_raw(Box::new(SpSession { receiver, b_scan }))) + })); + + match result { + Ok(Some(ptr)) => ptr, + _ => std::ptr::null_mut(), + } +} + +/// Destroys a session created by `api_session_create`. Safe to call with null. +#[no_mangle] +pub extern "C" fn api_session_destroy(session: *mut SpSession) { + if session.is_null() { + return; + } + unsafe { + drop(Box::from_raw(session)); + } +} + +/// Scans `outputs_data` against `tweak_bytes` using the given session. +/// Returns `"{}"` for the (overwhelmingly common) no-match case without a +/// serde round-trip, a JSON match map on a hit, or null on malformed input / +/// internal panic — callers must null-check before treating the result as a +/// string, and must still call `free_pointer` on any non-null result. +#[no_mangle] +pub extern "C" fn api_session_scan( + session: *mut SpSession, + outputs_data: *const *const OutputData, + outputs_data_len: u64, + tweak_bytes: *const u8, +) -> *mut i8 { + if session.is_null() || outputs_data.is_null() || tweak_bytes.is_null() { + return std::ptr::null_mut(); + } + + let result = catch_unwind(AssertUnwindSafe(|| { + let session = unsafe { &mut *session }; + + let outputs_slice = + unsafe { slice::from_raw_parts(outputs_data, outputs_data_len as usize) }; + let outputs_to_check: Vec = outputs_slice + .iter() + .filter_map(|&vout_data_ptr| { + if vout_data_ptr.is_null() { + return None; + } + let vout_data = unsafe { &*vout_data_ptr }; + let pubkey_slice = unsafe { slice::from_raw_parts(vout_data.pubkey_bytes, 32) }; + XOnlyPublicKey::from_slice(pubkey_slice).ok() + }) + .collect(); + + let tweak_data = + PublicKey::from_slice(unsafe { slice::from_raw_parts(tweak_bytes, 33) }).ok()?; + let shared_secret = calculate_shared_secret(tweak_data, session.b_scan).ok()?; + + let scanned = session + .receiver + .scan_transaction(&shared_secret, outputs_to_check) + .ok()?; + + if scanned.is_empty() { + return Some(ptr_from_str("{}")); + } + + let mut outputs: HashMap> = HashMap::new(); + for (label, output) in scanned { + let mut output_map = HashMap::new(); + for (x_only_pubkey, tweak) in output { + output_map.insert( + x_only_pubkey.to_string(), + tweak.to_be_bytes().as_hex().to_string(), + ); + } + let result_label = label.map(|l| l.as_string()).unwrap_or_else(|| "None".to_string()); + outputs.insert(result_label, output_map); + } + let serialized = serde_json::to_string(&outputs).ok()?; + Some(ptr_from_str(&serialized)) + })); + + match result { + Ok(Some(ptr)) => ptr, + _ => std::ptr::null_mut(), + } +} + +fn ptr_from_str(s: &str) -> *mut i8 { + CString::new(s).expect("no interior NUL in scan output").into_raw() as *mut i8 +} + +// --- v2 binary block decode+scan -------------------------------------------- +// +// Decodes+scans exactly one `blockchain.tweaks.subscribe` v2 block record, as +// specified byte-for-byte in electrs-tweaks's `doc/tweaks_v2_protocol.md` +// (that doc is the authoritative source for this layout — treat any +// discrepancy here as a bug in this file, not in that spec). The caller +// base64-decodes the wire blob and hands this function the raw bytes; the +// server confirmed one block per push notification (never batched), so one +// call here corresponds to exactly one notification (ADR-0008's "batch per +// response chunk" is satisfied trivially — a chunk *is* one block). +// +// Layout: {<32B txid, internal/consensus +// order><33B tweak>{<32B +// xonly>}}. `txid` on the wire is NOT display order — it is reversed here +// before hex-encoding so every match record this function returns already +// carries the conventional display-hex txid (matching `blockchain.tweaks.get` +// and every other txid the wallet handles), keeping the byte-order gotcha +// fully contained in this decoder. + +fn read_u32_le(buf: &[u8], pos: &mut usize) -> Option { + let bytes = buf.get(*pos..*pos + 4)?; + *pos += 4; + Some(u32::from_le_bytes(bytes.try_into().ok()?)) +} + +/// Bitcoin-consensus CompactSize varint (`0xfd`/`0xfe`/`0xff` prefix forms). +fn read_compact_size(buf: &[u8], pos: &mut usize) -> Option { + let first = *buf.get(*pos)?; + *pos += 1; + match first { + 0xfd => { + let b = buf.get(*pos..*pos + 2)?; + *pos += 2; + Some(u16::from_le_bytes(b.try_into().ok()?) as u64) + } + 0xfe => { + let b = buf.get(*pos..*pos + 4)?; + *pos += 4; + Some(u32::from_le_bytes(b.try_into().ok()?) as u64) + } + 0xff => { + let b = buf.get(*pos..*pos + 8)?; + *pos += 8; + Some(u64::from_le_bytes(b.try_into().ok()?)) + } + n => Some(n as u64), + } +} + +fn read_bytes<'a>(buf: &'a [u8], pos: &mut usize, n: usize) -> Option<&'a [u8]> { + let b = buf.get(*pos..*pos + n)?; + *pos += n; + Some(b) +} + +/// Decodes+scans one v2 block record (`block_bytes`, already base64-decoded +/// by the caller) against `session`'s persistent receiver. Returns a JSON +/// array of match records `{height, txid, vout, label, output_pubkey, +/// tweak}` (txid in display-hex order, per the note above), `"[]"` for the +/// overwhelmingly common no-match case (no serde round-trip), or null on a +/// malformed block / internal panic — same untrusted-input contract as the +/// rest of the session API (ADR-0006). +#[no_mangle] +pub extern "C" fn api_session_scan_block_v2( + session: *mut SpSession, + block_bytes: *const u8, + block_bytes_len: u64, +) -> *mut i8 { + if session.is_null() || block_bytes.is_null() { + return std::ptr::null_mut(); + } + + let result = catch_unwind(AssertUnwindSafe(|| -> Option<*mut i8> { + let session = unsafe { &mut *session }; + let buf = unsafe { slice::from_raw_parts(block_bytes, block_bytes_len as usize) }; + let mut pos = 0usize; + + let height = read_u32_le(buf, &mut pos)?; + let tx_count = read_compact_size(buf, &mut pos)?; + + let mut matches: Vec = Vec::new(); + + for _ in 0..tx_count { + let txid_wire = read_bytes(buf, &mut pos, 32)?; + // Wire order is internal/consensus order; every txid the wallet + // otherwise handles (v1 JSON, `blockchain.tweaks.get`) is the + // reversed display-hex string — reverse once, here, so nothing + // downstream of this function needs to know the wire order exists. + let mut txid_display = txid_wire.to_vec(); + txid_display.reverse(); + let txid_hex = txid_display.as_hex().to_string(); + + let tweak_bytes = read_bytes(buf, &mut pos, 33)?; + let tweak_data = PublicKey::from_slice(tweak_bytes).ok()?; + let shared_secret = calculate_shared_secret(tweak_data, session.b_scan).ok()?; + + let vout_count = read_compact_size(buf, &mut pos)?; + let mut vout_of: HashMap = HashMap::new(); + let mut outputs_to_check: Vec = Vec::with_capacity(vout_count as usize); + + for _ in 0..vout_count { + let vout = read_compact_size(buf, &mut pos)?; + let xonly_bytes = read_bytes(buf, &mut pos, 32)?; + // An unparseable xonly key is excluded from the scan set + // rather than failing the whole block, matching + // api_scan_outputs/api_session_scan's existing + // filter-and-skip behavior for malformed entries. + if let Ok(xonly) = XOnlyPublicKey::from_slice(xonly_bytes) { + vout_of.insert(xonly, vout); + outputs_to_check.push(xonly); + } + } + + let scanned = session + .receiver + .scan_transaction(&shared_secret, outputs_to_check) + .ok()?; + + for (label, outputs) in scanned { + let label_str = label.map(|l| l.as_string()).unwrap_or_else(|| "None".to_string()); + for (xonly, tweak_out) in outputs { + let vout = *vout_of.get(&xonly).unwrap_or(&0); + matches.push(serde_json::json!({ + "height": height, + "txid": txid_hex, + "vout": vout, + "label": label_str, + "output_pubkey": xonly.to_string(), + "tweak": tweak_out.to_be_bytes().as_hex().to_string(), + })); + } + } + } + + if matches.is_empty() { + return Some(ptr_from_str("[]")); + } + let serialized = serde_json::to_string(&matches).ok()?; + Some(ptr_from_str(&serialized)) + })); + + match result { + Ok(Some(ptr)) => ptr, + _ => std::ptr::null_mut(), + } +} + +/// Highest `blockchain.tweaks.subscribe` wire-protocol version this build's +/// decoder understands. `1` = JSON only (`api_scan_outputs`/`api_session_scan`). +/// `2` = the compact binary protocol (`api_session_scan_block_v2`), per +/// electrs-tweaks's `doc/tweaks_v2_protocol.md`. Client-side capability +/// negotiation must take `min(server-advertised, this)`, never the server's +/// offer alone, so a client can't attempt a version its own decoder can't +/// read. +#[no_mangle] +pub extern "C" fn api_max_wire_version() -> u32 { + 2 +} + #[no_mangle] pub extern "C" fn free_pointer(ptr: *mut c_char) { unsafe { diff --git a/test/package_test.dart b/test/package_test.dart deleted file mode 100644 index 05339aa..0000000 --- a/test/package_test.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:package/package.dart'; -import 'package:test/test.dart'; - -void main() { - test('calculate', () { - expect(calculate(), 42); - }); -} diff --git a/test/scan_block_v2_test.dart b/test/scan_block_v2_test.dart new file mode 100644 index 0000000..5208bce --- /dev/null +++ b/test/scan_block_v2_test.dart @@ -0,0 +1,109 @@ +// Correctness gate for the v2 binary block decoder (ScanSession.scanBlock) +// against the real fixture captured from electrs-tweaks's regtest build — +// see sp-scan-bench/docs/adr/0014-correctness-gate-covers-binary-decoder.md +// (this fixture must come from a real server, not a harness-synthesized +// one) and electrs-tweaks's doc/tweaks_v2_fixture.md (the capture itself, +// including the genuine BIP-352 payment built with sp-scan-bench/paygen and +// the exact receiver keys used to produce it). +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sp_scanner/sp_scanner.dart'; + +void main() { + // Receiver keys from doc/tweaks_v2_fixture.md — throwaway keys generated + // for that capture, not any real wallet's keys. + final bScanHex = '22' * 32; + final bSpendCompressedHex = + '023c72addb4fdf09af94f0c94d7fe92a386a7e70cf8a1d85916386bb2535c7b1b1'; + final receiver = Receiver(bScanHex, bSpendCompressedHex, false, const [], 0); + + // The same block (height 112), captured twice from the same server: once + // as v1 JSON, once as the v2 binary blob. Both describe the identical + // underlying TweakTxRow — see doc/tweaks_v2_fixture.md for the full + // capture and the worked txid byte-order example. + const v1TxidDisplayHex = + '38088f720c1f30e5c54a56e385984ebd11d6855b14d6158f8833771501709e68'; + const v1OutputPubkeyHex = + '46db9bd8d491531b2e783d32e07acb0624093fcdad75573e7e6da39ac21d0c13'; + // The block-level ECDH tweak (A_sum * input_hash) — the INPUT to scanning, + // used to derive the shared secret. Not to be confused with the per-match + // spend-key tweak scan_transaction returns, which is a different value + // computed FROM this one plus the receiver's own key material. + const blockTweakHex = + '0302cc2a75db0e06919f9d3312c17c831b68ff1ead95498a529fd366d64921e3c0'; + const v2Blob112Base64 = + 'cAAAAAFonnABFXcziI8V1hRbhdYRvU6YheNWSsXlMB8Mco8IOAMCzCp12w4GkZ+dMxLBfIMbaP8erZVJilKf02bWSSHjwAEARtub2NSRUxsueD0y4HrLBiQJP82tdVc+fm2jmsIdDBM='; + const v2Blob1EmptyBase64 = 'AQAAAAA='; // height 1, tx_count 0 + + group('scanBlock vs scanOutputs identity (real electrs-tweaks fixture)', () { + test('v1 path (production scanOutputs) matches the fixture', () { + final v1Result = scanOutputs([[v1OutputPubkeyHex]], blockTweakHex, receiver); + expect(v1Result, isNotEmpty); + expect((v1Result['None'] as Map).containsKey(v1OutputPubkeyHex), isTrue); + }); + + test('v2 path (ScanSession.scanBlock) matches the fixture and agrees with v1', () { + final v1Result = scanOutputs([[v1OutputPubkeyHex]], blockTweakHex, receiver); + final v1SpendTweak = (v1Result['None'] as Map)[v1OutputPubkeyHex]; + + final session = ScanSession.create(receiver); + addTearDown(session.dispose); + + final matches = session.scanBlock(base64Decode(v2Blob112Base64)); + + expect(matches, hasLength(1)); + final match = matches.first as Map; + expect(match['height'], 112); + expect(match['txid'], v1TxidDisplayHex, + reason: 'v2 txid must already be reversed to display-hex order by the decoder'); + expect(match['vout'], 0); + expect(match['label'], 'None'); + expect(match['output_pubkey'], v1OutputPubkeyHex); + expect(match['tweak'], v1SpendTweak, + reason: 'v2 decode-and-scan must agree with the independent v1 parse-then-scan path ' + 'on the derived spend-key tweak, not just the match itself'); + }); + + test('a negative-control receiver does not match either path', () { + final otherReceiver = Receiver('33' * 32, bSpendCompressedHex, false, const [], 0); + + final v1Result = scanOutputs([[v1OutputPubkeyHex]], blockTweakHex, otherReceiver); + expect(v1Result, isEmpty); + + final session = ScanSession.create(otherReceiver); + addTearDown(session.dispose); + expect(session.scanBlock(base64Decode(v2Blob112Base64)), isEmpty); + }); + }); + + group('scanBlock decoder edge cases', () { + test('a zero-tx block (the empty-tail bookmark case) decodes to no matches', () { + final session = ScanSession.create(receiver); + addTearDown(session.dispose); + expect(session.scanBlock(base64Decode(v2Blob1EmptyBase64)), isEmpty); + }); + + test('a truncated block throws instead of crashing', () { + final session = ScanSession.create(receiver); + addTearDown(session.dispose); + + final fullBlock = base64Decode(v2Blob112Base64); + // Cut off mid-header: not even a full u32 height is present. + final truncated = fullBlock.sublist(0, 2); + + expect(() => session.scanBlock(truncated), throwsA(isA())); + }); + + test('a block whose declared tx_count exceeds the actual bytes throws', () { + final session = ScanSession.create(receiver); + addTearDown(session.dispose); + + // Valid height, but tx_count says 5 with nothing else following. + final malformed = Uint8List.fromList(base64Decode(v2Blob1EmptyBase64)); + malformed[4] = 5; // was tx_count = 0 + expect(() => session.scanBlock(malformed), throwsA(isA())); + }); + }); +} diff --git a/test/scan_session_test.dart b/test/scan_session_test.dart new file mode 100644 index 0000000..986144a --- /dev/null +++ b/test/scan_session_test.dart @@ -0,0 +1,166 @@ +// Correctness gate for the persistent-session scan API (ScanSession) against +// the production stateless API (scanOutputs): both must agree byte-for-byte +// on real Silent Payment scan results. +// +// Ported from sp-scan-bench/bin/synthetic_test.dart (the investigation that +// designed the session API) — see sp-scan-bench/docs/adr/0005 and 0014 for +// why this is the required merge gate for the session API, and +// sp-scan-bench/docs/regtest_e2e.md for how the sender-side math here was +// validated against a real BIP-352 indexer. +import 'dart:convert'; + +import 'package:bip39/bip39.dart' as bip39; +import 'package:blockchain_utils/blockchain_utils.dart'; +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sp_scanner/sp_scanner.dart'; + +const _seed = + 'nominee prepare verify canoe garage change diamond digital dad tower cliff acid cute dress rare vocal sleep alien useless smile lecture opinion size wash'; + +void main() { + final seedBytes = bip39.mnemonicToSeed(_seed); + final masterHD = Bip32Slip10Secp256k1.fromSeed(seedBytes); + final bScan = ECPrivate.fromHex(masterHD.derivePath("m/352'/0'/0'/1'/0").privateKey.toHex()); + final bSpend = ECPrivate.fromHex(masterHD.derivePath("m/352'/0'/0'/0'/0").privateKey.toHex()); + final owner = SilentPaymentOwner.fromPrivateKeys( + b_scan: bScan, b_spend: bSpend, network: BitcoinNetwork.mainnet); + + final bScanAdvanced = + ECPrivate.fromHex(masterHD.derivePath("m/352'/0'/0'/1'/1").privateKey.toHex()); + final bSpendAdvanced = + ECPrivate.fromHex(masterHD.derivePath("m/352'/0'/0'/0'/1").privateKey.toHex()); + + group('ScanSession vs scanOutputs identity', () { + test('default address: match + decoy rejection, identical across both scanners', () { + final senderPriv = ECPrivate.fromHex( + '1111111111111111111111111111111111111111111111111111111111111111'); + final senderPub = senderPriv.getPublic(); + final outpoint = Outpoint.fromBytes(List.filled(32, 0xff), 0); + final builder = SilentPaymentBuilder(vinOutpoints: [outpoint], pubkeys: [senderPub]); + final destination = SilentPaymentDestination( + scanPubkey: owner.b_scan.getPublic(), + spendPubkey: owner.B_spend, + version: owner.version, + network: BitcoinNetwork.mainnet, + amount: 50000, + ); + final outputs = builder.createOutputs([ECPrivateInfo(senderPriv, true)], [destination]); + final p2tr = outputs.values.first.first.address; + final outputPubkeyHex = p2tr.addressProgram; + final tweakHex = builder.A_sum!.tweakMul(BigintUtils.fromBytes(builder.inputHash!)).toHex(); + + final prepared = [ + [outputPubkeyHex], + ['02deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'], // decoy + ]; + + final receivers = [ + (owner.b_scan.toHex(), owner.B_spend.toHex()), // real receiver: must match + ( + masterHD.derivePath("m/352'/1'/0'/1'/0").privateKey.toHex(), + masterHD.derivePath("m/352'/1'/0'/0'/0").publicKey.toHex(), + ), // negative-control receiver: must not match, on either scanner + ]; + + for (var r = 0; r < receivers.length; r++) { + final (b, B) = receivers[r]; + final prod = scanOutputs(prepared, tweakHex, Receiver(b, B, false, const [], 0)); + + final session = ScanSession.create(Receiver(b, B, false, const [], 0)); + final fast = session.scan(prepared, tweakHex); + session.dispose(); + + expect(jsonEncode(fast), jsonEncode(prod), + reason: 'receiver $r: session scan result must byte-match scanOutputs'); + + if (r == 0) { + expect(prod, isNotEmpty, reason: 'the real receiver must match its own payment'); + } else { + expect(prod, isEmpty, reason: 'the negative-control receiver must not match'); + } + } + }); + + test('labeled address (index 1): match identical across both scanners', () { + final senderPriv2 = ECPrivate.fromHex( + '2222222222222222222222222222222222222222222222222222222222222222'); + final builder2 = SilentPaymentBuilder( + vinOutpoints: [Outpoint.fromBytes(List.filled(32, 0xee), 1)], + pubkeys: [senderPriv2.getPublic()], + ); + final dest2 = SilentPaymentDestination( + scanPubkey: bScanAdvanced.getPublic(), + spendPubkey: bSpendAdvanced.getPublic(), + version: 0, + network: BitcoinNetwork.mainnet, + amount: 30000, + ); + final outp2 = + builder2.createOutputs([ECPrivateInfo(senderPriv2, true)], [dest2]).values.first.first; + final outPub2 = outp2.address.addressProgram; + final tweak2 = builder2.A_sum!.tweakMul(BigintUtils.fromBytes(builder2.inputHash!)).toHex(); + + final prepared2 = [ + [outPub2], + ['03ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'], + ]; + + final receiver = Receiver( + bScanAdvanced.toHex(), + bSpendAdvanced.getPublic().toHex(), + false, + [1], + 1, + ); + final prod2 = scanOutputs(prepared2, tweak2, receiver); + + final session2 = ScanSession.create(receiver); + final fast2 = session2.scan(prepared2, tweak2); + session2.dispose(); + + expect(jsonEncode(fast2), jsonEncode(prod2), + reason: 'labeled-address session scan result must byte-match scanOutputs'); + expect(prod2, isNotEmpty, reason: 'the labeled address must match its own payment'); + }); + }); + + group('ScanSession error handling (native-side validation, not Dart hex parsing)', () { + // Well-formed hex of the right byte length, but not a valid secp256k1 + // scalar/point — reaches the native validation (SecretKey/PublicKey + // ::from_slice) rather than failing earlier at Dart's hex decoding, so + // this actually exercises the ADR-0006 null-on-malformed-input path. + final invalidScalarHex = '00' * 32; // zero is not a valid secp256k1 scalar + final invalidPointHex = '00' * 33; // not a valid compressed point encoding + + test('invalid scan-key bytes throw instead of crashing', () { + expect( + () => ScanSession.create( + Receiver(invalidScalarHex, owner.B_spend.toHex(), false, const [], 0)), + throwsA(isA()), + ); + }); + + test('invalid tweak bytes throw instead of crashing', () { + final session = ScanSession.create(Receiver( + bScan.toHex(), + owner.B_spend.toHex(), + false, + const [], + 0, + )); + addTearDown(session.dispose); + + expect( + () => session.scan([ + [invalidPointHex.substring(0, 64)] // 32-byte x-only pubkey slot + ], invalidPointHex), + throwsA(isA()), + ); + }); + }); + + test('maxWireVersion reports a supported version', () { + expect(maxWireVersion(), greaterThanOrEqualTo(1)); + }); +}