From a4752e25bd340ede0ca44eeb88f54f7226ff0ae8 Mon Sep 17 00:00:00 2001 From: Rastislav Date: Wed, 26 Aug 2026 13:34:54 +0200 Subject: [PATCH] init --- .github/FUNDING.yml | 3 + .github/ISSUE_TEMPLATE/bug.yml | 52 ++++++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature.yml | 37 ++++ .github/workflows/publish.yml | 27 +++ .github/workflows/test.yml | 32 ++++ .gitignore | 11 ++ CHANGELOG.md | 9 + LICENSE | 19 ++ PROTOCOL.md | 86 +++++++++ README.md | 121 ++++++++++++- SECURITY.md | 29 +++ analysis_options.yaml | 5 + example/README.md | 11 ++ example/lib/main.dart | 85 +++++++++ example/pubspec.yaml | 18 ++ lib/flutter_barcodetx.dart | 10 ++ lib/src/decoder/barcode_tx_decoder.dart | 112 ++++++++++++ lib/src/encoder/barcode_tx_encoder.dart | 71 ++++++++ lib/src/exceptions/exceptions.dart | 25 +++ lib/src/fountain/decoder.dart | 108 +++++++++++ lib/src/fountain/encoder.dart | 52 ++++++ lib/src/fountain/symbols.dart | 44 +++++ lib/src/models/batch.dart | 14 ++ lib/src/models/options.dart | 55 ++++++ lib/src/models/transaction.dart | 84 +++++++++ lib/src/protocol/bytes.dart | 31 ++++ lib/src/protocol/crc32.dart | 10 ++ lib/src/protocol/frame.dart | 104 +++++++++++ lib/src/protocol/integrity.dart | 9 + lib/src/protocol/serializer.dart | 113 ++++++++++++ lib/src/qr/animated_qr.dart | 87 +++++++++ pubspec.yaml | 41 +++++ test/flutter_barcodetx_test.dart | 230 ++++++++++++++++++++++++ tool/benchmark.dart | 47 +++++ 35 files changed, 1798 insertions(+), 2 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 PROTOCOL.md create mode 100644 SECURITY.md create mode 100644 analysis_options.yaml create mode 100644 example/README.md create mode 100644 example/lib/main.dart create mode 100644 example/pubspec.yaml create mode 100644 lib/flutter_barcodetx.dart create mode 100644 lib/src/decoder/barcode_tx_decoder.dart create mode 100644 lib/src/encoder/barcode_tx_encoder.dart create mode 100644 lib/src/exceptions/exceptions.dart create mode 100644 lib/src/fountain/decoder.dart create mode 100644 lib/src/fountain/encoder.dart create mode 100644 lib/src/fountain/symbols.dart create mode 100644 lib/src/models/batch.dart create mode 100644 lib/src/models/options.dart create mode 100644 lib/src/models/transaction.dart create mode 100644 lib/src/protocol/bytes.dart create mode 100644 lib/src/protocol/crc32.dart create mode 100644 lib/src/protocol/frame.dart create mode 100644 lib/src/protocol/integrity.dart create mode 100644 lib/src/protocol/serializer.dart create mode 100644 lib/src/qr/animated_qr.dart create mode 100644 pubspec.yaml create mode 100644 test/flutter_barcodetx_test.dart create mode 100644 tool/benchmark.dart diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..19260b5 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +github: [bchainhub] +custom: + - https://blockindex.net/address/cb7147879011ea207df5b35a24ca6f0859dcfb145999 diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..8703ae4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,52 @@ +name: Report Bug 🪲 +description: Report a reproducible flutter_barcodetx problem +title: "[Bug]: " +labels: [bug] +body: + - type: markdown + attributes: + value: Never include private keys, recovery phrases, or production signed transactions. Use synthetic data. + - type: input + id: version + attributes: + label: Package version + placeholder: 0.1.0 + validations: + required: true + - type: input + id: flutter + attributes: + label: Flutter version + description: Output of `flutter --version` + validations: + required: true + - type: input + id: platform + attributes: + label: Target platform and OS + placeholder: Android 16, iOS 19, Chrome 140, macOS 16 + validations: + required: true + - type: textarea + id: description + attributes: + label: Description + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Minimal reproduction + description: Include synthetic input, options, and frame ordering/drop behavior. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs or exception diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..fe097c6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Protocol documentation + url: https://github.com/DataLayerHost/flutter_barcodetx/blob/main/PROTOCOL.md + about: Read the wire format and interoperability rules + - name: Security reports + url: https://github.com/DataLayerHost/flutter_barcodetx/security/advisories/new + about: Privately report a security vulnerability diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..4580ca5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,37 @@ +name: Feature Request 🔮 +description: Propose an API, protocol, QR, or tooling improvement +title: "[Feature]: " +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What transport use case is not currently served? + validations: + required: true + - type: dropdown + id: compatibility + attributes: + label: Wire compatibility + options: + - No wire-format change + - Backward-compatible extension + - New protocol version + - Unsure + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: textarea + id: api + attributes: + label: Proposed Dart API or wire example diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..72cde38 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,27 @@ +name: Publish to pub.dev + +on: + push: + tags: ['*.*.*'] + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + - run: flutter pub get + - run: dart format --output=none --set-exit-if-changed . + - run: flutter analyze + - run: flutter test + - run: flutter pub publish --dry-run + + publish: + needs: validate + permissions: + id-token: write + uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..626d266 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,32 @@ +name: Testing + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + - run: flutter pub get + - run: dart format --output=none --set-exit-if-changed . + - run: flutter analyze + - run: flutter test + - run: dart run tool/benchmark.dart + - run: flutter pub publish --dry-run + - name: Analyze scanner example + working-directory: example + run: | + flutter pub get + flutter analyze diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f8f9629 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +/build/ +/coverage/ +pubspec.lock +.DS_Store +.idea/ +.vscode/ +*.iml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fac348d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## 0.1.0 + +- Initial BarcodeTX v1 binary batch and frame protocol. +- Deterministic systematic LT-style fountain encoder with Gaussian decoder. +- Raw-byte animated QR widget and camera-independent decoder. +- SHA-256 end-to-end integrity, 48-bit transfer grouping, and frame CRC-32. +- Typed/map APIs, defensive limits, protocol documentation, scanner example, benchmark, and test suite. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8119017 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +CORE License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +All distribution of the Covered Software in Source Code Form, including any +Modifications and/or Contributions must be disclosed and publicly available. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE TO ANY CLAIM, DAMAGES OR +OTHER LIABILITIES, WHETHER IN AN ACTION OF A CONTRACT, TORT, OR OTHERWISE, +ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE, OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/PROTOCOL.md b/PROTOCOL.md new file mode 100644 index 0000000..2660286 --- /dev/null +++ b/PROTOCOL.md @@ -0,0 +1,86 @@ +# BarcodeTX Protocol v1 + +All integers are unsigned and big-endian. Offsets are zero-based. Text is strict ASCII. Decoders must reject reserved values, trailing batch bytes, inconsistent metadata, arithmetic overflow, and lengths exceeding local limits before allocation. + +## Canonical transaction batch + +| Offset | Size | Field | Value | +| ---: | ---: | --- | --- | +| 0 | 4 | magic | `42 54 58 42` (`BTXB`) | +| 4 | 1 | version | `01` | +| 5 | 2 | transaction count | 1–256 by default | +| 7 | variable | transaction entries | In original input order | + +Each entry is contiguous: + +| Relative offset | Size | Field | +| ---: | ---: | --- | +| 0 | 1 | `TYPE_LENGTH` | +| 1 | `TYPE_LENGTH` | uppercase ASCII type | +| `1 + TYPE_LENGTH` | 4 | raw transaction byte length | +| `5 + TYPE_LENGTH` | declared length | opaque signed transaction bytes | + +The type grammar is `[A-Z0-9][A-Z0-9._+-]*`. Default limits are 32 type bytes, 512 KiB per transaction, 256 transactions, and 1 MiB for the complete canonical batch. Zero-length transactions are invalid. Public hex is decoded pairwise to these raw bytes. + +## Protected fountain payload and transfer ID + +Let `B` be the complete canonical batch bytes and `H = SHA-256(B)` (32 bytes). The fountain payload is: + +```text +P = B || H +``` + +The transfer ID is `H[0:6]`, exposed as 12 uppercase hexadecimal characters. Six truncated digest bytes provide grouping and accidental-collision avoidance only, not authentication. On completion a decoder must verify both `SHA-256(B) == H` and `H[0:6] == frame transfer ID` before deserializing. + +## Source blocks + +`P` is divided in order into `K = ceil(length(P) / S)` blocks of exactly `S` bytes. `S` is the frame's block-size field. Only the final block is right-padded with zero bytes. `K` must equal the frame source-count field. After reconstruction, padding is removed using the declared protected-payload length. + +## Deterministic fountain symbols + +Every symbol has a 32-bit `SYMBOL_ID` and is the bitwise XOR of selected source blocks: + +- If `SYMBOL_ID < K`, select only block `SYMBOL_ID` (systematic degree 1). +- Otherwise seed a 32-bit state with `(SYMBOL_ID * 0x9E3779B1) mod 2^32`, then for transfer-ID byte `t[i]`, in order, set `state = state XOR (t[i] << (8 * (i mod 4)))`. +- `next()` applies unsigned 32-bit xorshift32: `state ^= state << 13; state ^= state >>> 17; state ^= state << 5`, truncating to 32 bits after each left shift and returning the 32-bit state. +- Let `Dmax = min(K, 8)`. For `K == 1`, degree is 1. Otherwise degree is `2 + (next() mod (Dmax - 1))`, producing 2 through `Dmax`. +- Repeatedly select `next() mod K` into a set until the set contains `degree` distinct indices. XOR those source blocks. Sorting the selected indices is recommended for deterministic implementations but does not change XOR output. + +The decoder independently derives the selection and rejects a mismatched transmitted degree. It collects binary linear equations and uses Gaussian elimination over GF(2). It completes only at rank `K`; duplicate symbol IDs are ignored. This is a systematic LT-style fountain/rateless erasure code. It does not promise a fixed overhead: senders continue producing symbols until the receiver reports completion. + +## Binary QR frame + +The total frame size is `27 + S` bytes. + +| Offset | Size | Field | v1 value / meaning | +| ---: | ---: | --- | --- | +| 0 | 2 | magic | `42 58` (`BX`) | +| 2 | 1 | frame version | `01` | +| 3 | 1 | flags | `00`; all bits reserved | +| 4 | 6 | transfer ID | raw `H[0:6]` | +| 10 | 4 | protected-payload length | `length(P)` | +| 14 | 2 | source block size `S` | raw symbol data length | +| 16 | 2 | source block count `K` | exactly `ceil(length(P)/S)` | +| 18 | 4 | symbol ID | deterministic fountain sequence number | +| 22 | 1 | degree | 1–8 and equal to derived degree | +| 23 | `S` | fountain symbol | XOR result | +| `23 + S` | 4 | frame CRC-32 | CRC of every preceding frame byte | + +CRC-32 is the IEEE reflected polynomial `0xEDB88320`, initial state `0xFFFFFFFF`, bytewise input, final XOR `0xFFFFFFFF`. It detects corrupted symbol/metadata bytes before solving; it is not cryptographic authentication. + +The default `S` is 773 because the default maximum frame is 800 bytes. The configured maximum frame must be at least 28 bytes and no larger than the QR version-40 byte-mode capacity at the selected error correction: L 2953, M 2331, Q 1663, or H 1273 bytes. Applications should prefer the conservative default. + +## Decoder behavior + +1. Check maximum input size, minimum size, magic, version, flags, and CRC. +2. Validate protected length, block size, source count, degree, and exact frame length without allocation from unchecked fields. +3. The first valid frame selects its transfer ID and fixed fountain metadata. +4. Ignore different-transfer frames. Reject same-transfer frames with inconsistent metadata. +5. Deduplicate IDs, derive their coefficients, and insert equations in arbitrary arrival order. +6. At full rank reconstruct `P`, remove only declared right padding, verify SHA-256 and transfer ID, then strictly deserialize `B`. + +Default receipt is capped at 8,192 distinct symbol IDs. Applications may lower limits. A decoder instance handles one transfer; create a new instance to scan another. + +## Layer separation + +QR visual Reed-Solomon repairs module-level scan errors. Fountain coding repairs missing complete frames. CRC-32 rejects malformed individual binary envelopes. SHA-256 verifies reconstructed payload integrity. The 48-bit ID separates simultaneous sessions. None authenticates the sender. diff --git a/README.md b/README.md index 77bc7e7..7348820 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,119 @@ -# flutter_barcodetx -Animated Barcode Transactions +# Flutter BarcodeTX + +`flutter_barcodetx` transports one or more already-signed blockchain transactions between devices with an unbounded animated QR stream and deterministic fountain-style erasure coding. + +[![pub package](https://img.shields.io/pub/v/flutter_barcodetx.svg)](https://pub.dev/packages/flutter_barcodetx) +[![License: CORE](https://img.shields.io/badge/License-CORE-yellow.svg)](LICENSE) +[![Testing](https://github.com/DataLayerHost/flutter_barcodetx/actions/workflows/test.yml/badge.svg)](https://github.com/DataLayerHost/flutter_barcodetx/actions/workflows/test.yml) + +BarcodeTX treats every signed transaction as opaque bytes. It does not sign, broadcast, inspect chain semantics, validate consensus rules, calculate transaction hashes, or handle private keys. Arbitrary caller-provided chain identifiers are supported. + +## Pipeline + +```mermaid +flowchart TD + input["Transaction objects
{TYPE: XCB, HEX: ...}
{TYPE: BTC, HEX: ...}"] + normalize["Normalize type and hex"] + raw["Convert hex to raw bytes"] + batch["Create versioned binary batch"] + integrity["Add SHA-256 integrity"] + encode["Fountain encoder"] + qr["QR → QR → QR → QR → …"] + camera["Camera"] + bytes["Decoded QR bytes"] + decode["Fountain decoder"] + verify["Verify and deserialize"] + output["Canonical transaction objects
{TYPE: XCB, HEX: ...}
{TYPE: BTC, HEX: ...}"] + + input --> normalize --> raw --> batch --> integrity --> encode + encode --> qr --> camera --> bytes --> decode --> verify --> output +``` + +Transaction types and public hex output are uppercase. An input `0x`/`0X` prefix is accepted and removed; reconstructed hex never includes a prefix. Hex is converted to raw bytes before serialization, so `AB12CD34` occupies four transaction bytes rather than eight ASCII bytes. + +## Installation + +```sh +flutter pub add flutter_barcodetx +``` + +## Encode and display + +```dart +import 'package:flutter_barcodetx/flutter_barcodetx.dart'; + +final transactions = [ + BarcodeTxTransaction(type: 'xcb', hex: '0xABCD'), + BarcodeTxTransaction(type: 'btc', hex: '02000000'), +]; + +final encoder = BarcodeTxEncoder(transactions: transactions); +print(encoder.transferId); // 12 uppercase hexadecimal characters + +encoder.frames.listen((frame) { + sendToQrRenderer(frame.bytes); // compact binary, not Base64 +}); +``` + +Map input is also accepted: + +```dart +final encoder = BarcodeTxEncoder(transactions: [ + {'type': 'xcb', 'hex': '0xabcd'}, + {'type': 'btc', 'hex': '02000000'}, +]); +``` + +Use the included widget to render the continuous stream: + +```dart +BarcodeTxAnimatedQr( + transactions: transactions, + size: 320, + frameDuration: const Duration(milliseconds: 150), +) +``` + +Tune QR density and defensive limits with `BarcodeTxOptions`. The default frame is at most 800 bytes and uses QR error correction level M. + +## Decode camera output + +The core decoder has no camera dependency. Feed it the raw byte payload returned by a scanner: + +```dart +final decoder = BarcodeTxDecoder(); + +for (final bytes in scannerRawByteStream) { + final state = decoder.addFrame(bytes); + print('${(state.progress * 100).round()}%'); + + if (state.isComplete) { + for (final transaction in decoder.transactions) { + print('${transaction.type}: ${transaction.hex}'); + } + } +} +``` + +Once the first valid frame selects a transfer, frames from another transfer are ignored and return `accepted: false`. Duplicates and arbitrary ordering are supported. See the runnable [`mobile_scanner` example](example/) for a progress UI and raw-byte camera integration. + +## Recovery and integrity + +- QR Reed-Solomon error correction helps recover a visually damaged QR symbol. +- Fountain equations recover source blocks when entire displayed frames are missed. +- A compact CRC-32 rejects a damaged individual frame before it enters the solver. +- SHA-256 verifies the entire reconstructed canonical batch end to end. +- The first six SHA-256 bytes form a transfer ID for session grouping and accidental-collision avoidance; it is not authentication. + +The codec begins with systematic degree-one symbols, then emits an effectively unbounded deterministic stream of degree 2–8 XOR equations. The decoder performs binary Gaussian elimination and completes only at full rank. This is real erasure coding, not `N/N` chunk sequencing. For adversarial tampering, use an authenticated transport or verify transaction details through a trusted application workflow. + +## Documentation + +- [Binary interoperability protocol](PROTOCOL.md) +- [Security model and limits](SECURITY.md) +- [Camera example](example/) +- [Runtime benchmark](tool/benchmark.dart) + +## License + +Licensed under the [CORE License](LICENSE). Source distributions, modifications, and contributions must remain publicly available. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..523b094 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security + +## Scope + +BarcodeTX transports caller-supplied, already-signed transaction bytes. It never handles private keys, signs or broadcasts transactions, interprets chain formats, validates consensus, or establishes that a transaction is safe. Applications must show and independently validate the destination, amount, network, fees, and other relevant details before broadcast. + +## Trust model + +Scanned frames are untrusted. Version 1 provides accidental-corruption detection, erasure recovery, full-payload integrity, and transfer grouping. SHA-256 here is unkeyed: an attacker able to replace the complete stream can create another internally valid stream. The six-byte transfer ID is not an authenticator and has 48-bit collision resistance at most. + +For authenticity, bind the transaction to an authenticated workflow outside BarcodeTX—for example, compare details on a trusted signing device or carry a signature from an independently trusted identity. + +## Defensive defaults + +- maximum QR payload: 800 bytes +- maximum canonical batch: 1 MiB +- maximum transaction: 512 KiB +- maximum transactions: 256 +- maximum type: 32 ASCII bytes +- maximum accepted distinct symbols: 8,192 +- exact length/count consistency and no trailing batch bytes +- CRC-32 before fountain insertion +- SHA-256 and transfer-ID verification before returning data + +Applications processing smaller transactions should lower these limits. Do not raise them from values supplied by a QR frame. + +## Reporting vulnerabilities + +Please report security issues privately through GitHub's security advisory interface for `DataLayerHost/flutter_barcodetx`. Never include private keys, seed phrases, or production signed transactions in a report; use synthetic test vectors. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..6ac1e59 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + prefer_single_quotes: true diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..1a8f0de --- /dev/null +++ b/example/README.md @@ -0,0 +1,11 @@ +# BarcodeTX scanner example + +This example uses `mobile_scanner` only as a camera adapter. The byte list in `Barcode.rawDecodedBytes` is passed directly to `BarcodeTxDecoder.addFrame`; the BarcodeTX package itself has no scanner dependency. + +```sh +cd example +flutter pub get +flutter run +``` + +Add the camera permission required by `mobile_scanner` to the generated Android/iOS host before running on a device. The example intentionally does not fall back to `rawValue`, because a UTF-8 string cannot losslessly represent an arbitrary binary BarcodeTX frame. diff --git a/example/lib/main.dart b/example/lib/main.dart new file mode 100644 index 0000000..4734702 --- /dev/null +++ b/example/lib/main.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_barcodetx/flutter_barcodetx.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +void main() => runApp(const BarcodeTxExampleApp()); + +class BarcodeTxExampleApp extends StatelessWidget { + const BarcodeTxExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: ScannerPage()); + } +} + +class ScannerPage extends StatefulWidget { + const ScannerPage({super.key}); + + @override + State createState() => _ScannerPageState(); +} + +class _ScannerPageState extends State { + final decoder = BarcodeTxDecoder(); + Object? error; + + void _scan(BarcodeCapture capture) { + if (decoder.isComplete) return; + for (final barcode in capture.barcodes) { + final bytes = switch (barcode.rawDecodedBytes) { + DecodedBarcodeBytes(:final bytes) => bytes, + DecodedVisionBarcodeBytes(:final bytes) => bytes, + null => null, + }; + if (bytes == null) continue; + try { + decoder.addFrame(bytes); + error = null; + } on BarcodeTxException catch (caught) { + error = caught; + } + } + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + final complete = decoder.isComplete; + return Scaffold( + appBar: AppBar(title: const Text('BarcodeTX')), + body: Column( + children: [ + if (!complete) Expanded(child: MobileScanner(onDetect: _scan)), + Padding( + padding: const EdgeInsets.all(20), + child: complete ? _result() : _progress(), + ), + ], + ), + ); + } + + Widget _progress() => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('SCANNING TRANSACTION'), + Text('TRANSFER: ${decoder.transferId ?? "WAITING"}'), + Text('PROGRESS: ${(decoder.progress * 100).round()}%'), + Text('RECEIVED: ${decoder.receivedSymbols} SYMBOLS'), + if (error != null) Text('IGNORED FRAME: $error'), + ], + ); + + Widget _result() => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${decoder.transactions.length} TRANSACTIONS RECEIVED'), + for (final transaction in decoder.transactions) ...[ + const SizedBox(height: 12), + Text(transaction.type), + SelectableText(transaction.hex), + ], + ], + ); +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml new file mode 100644 index 0000000..1ca56a8 --- /dev/null +++ b/example/pubspec.yaml @@ -0,0 +1,18 @@ +name: flutter_barcodetx_example +publish_to: none + +environment: + sdk: ">=3.7.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + flutter_barcodetx: + path: ../ + mobile_scanner: ^7.4.0 + +dev_dependencies: + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true diff --git a/lib/flutter_barcodetx.dart b/lib/flutter_barcodetx.dart new file mode 100644 index 0000000..8b934a4 --- /dev/null +++ b/lib/flutter_barcodetx.dart @@ -0,0 +1,10 @@ +library; + +export 'src/decoder/barcode_tx_decoder.dart'; +export 'src/encoder/barcode_tx_encoder.dart'; +export 'src/exceptions/exceptions.dart'; +export 'src/models/batch.dart'; +export 'src/models/options.dart'; +export 'src/models/transaction.dart'; +export 'src/protocol/frame.dart' show BarcodeTxFrame; +export 'src/qr/animated_qr.dart'; diff --git a/lib/src/decoder/barcode_tx_decoder.dart b/lib/src/decoder/barcode_tx_decoder.dart new file mode 100644 index 0000000..98c2e02 --- /dev/null +++ b/lib/src/decoder/barcode_tx_decoder.dart @@ -0,0 +1,112 @@ +import 'dart:typed_data'; + +import '../exceptions/exceptions.dart'; +import '../fountain/decoder.dart'; +import '../models/batch.dart'; +import '../models/options.dart'; +import '../models/transaction.dart'; +import '../protocol/bytes.dart'; +import '../protocol/frame.dart'; +import '../protocol/integrity.dart'; +import '../protocol/serializer.dart'; + +final class BarcodeTxDecoderState { + const BarcodeTxDecoderState({ + required this.progress, + required this.isComplete, + required this.receivedSymbols, + required this.transferId, + required this.accepted, + }); + + final double progress; + final bool isComplete; + final int receivedSymbols; + final String? transferId; + final bool accepted; +} + +final class BarcodeTxDecoder { + BarcodeTxDecoder({this.options = const BarcodeTxOptions()}) { + options.validate(); + } + + final BarcodeTxOptions options; + FountainDecoder? _fountain; + Uint8List? _transferIdBytes; + BarcodeTxBatch? _result; + + double get progress => _fountain?.progress ?? 0; + bool get isComplete => _result != null; + String? get transferId => + _transferIdBytes == null ? null : bytesToHex(_transferIdBytes!); + int get receivedSymbols => _fountain?.receivedSymbols ?? 0; + BarcodeTxBatch? get result => _result; + List get transactions => + _result?.transactions ?? + (throw StateError('BarcodeTX transfer is not complete.')); + List> get transactionMaps => + _result?.toMaps() ?? + (throw StateError('BarcodeTX transfer is not complete.')); + + BarcodeTxDecoderState addFrame(List frameBytes) { + if (frameBytes.length > options.maxQrPayloadBytes) { + throw const BarcodeTxLimitException( + 'QR frame exceeds the configured maximum size.'); + } + final frame = BarcodeTxFrame.parse(Uint8List.fromList(frameBytes), options); + if (_transferIdBytes != null && + !constantTimeEquals(frame.transferIdBytes, _transferIdBytes!)) { + return _state(accepted: false); + } + if (_fountain == null) { + _transferIdBytes = Uint8List.fromList(frame.transferIdBytes); + _fountain = FountainDecoder( + payloadLength: frame.payloadLength, + blockSize: frame.blockSize, + sourceBlockCount: frame.sourceBlockCount, + transferId: _transferIdBytes!, + maxReceivedSymbols: options.maxReceivedSymbols, + ); + } else if (frame.payloadLength != _fountain!.payloadLength || + frame.blockSize != _fountain!.blockSize || + frame.sourceBlockCount != _fountain!.sourceBlockCount) { + throw const BarcodeTxProtocolException( + 'Inconsistent fountain metadata for transfer.'); + } + + _fountain!.add(frame); + if (_fountain!.isComplete && _result == null) _finish(_fountain!.result!); + return _state(accepted: true); + } + + void _finish(Uint8List protectedPayload) { + if (protectedPayload.length < 33) { + throw const BarcodeTxProtocolException( + 'Protected batch payload is too short.'); + } + final digestOffset = protectedPayload.length - 32; + final batchBytes = Uint8List.sublistView(protectedPayload, 0, digestOffset); + final suppliedDigest = + Uint8List.sublistView(protectedPayload, digestOffset); + final actualDigest = barcodeTxDigest(batchBytes); + if (!constantTimeEquals(suppliedDigest, actualDigest)) { + throw const BarcodeTxIntegrityException( + 'Reconstructed batch SHA-256 mismatch.'); + } + if (!constantTimeEquals(_transferIdBytes!, actualDigest.sublist(0, 6))) { + throw const BarcodeTxIntegrityException( + 'Reconstructed batch transfer ID mismatch.'); + } + _result = BarcodeTxSerializer.deserialize(batchBytes, options); + } + + BarcodeTxDecoderState _state({required bool accepted}) => + BarcodeTxDecoderState( + progress: progress, + isComplete: isComplete, + receivedSymbols: receivedSymbols, + transferId: transferId, + accepted: accepted, + ); +} diff --git a/lib/src/encoder/barcode_tx_encoder.dart b/lib/src/encoder/barcode_tx_encoder.dart new file mode 100644 index 0000000..f20ccf5 --- /dev/null +++ b/lib/src/encoder/barcode_tx_encoder.dart @@ -0,0 +1,71 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import '../fountain/encoder.dart'; +import '../models/batch.dart'; +import '../models/options.dart'; +import '../models/transaction.dart'; +import '../protocol/bytes.dart'; +import '../protocol/frame.dart'; +import '../protocol/integrity.dart'; +import '../protocol/serializer.dart'; + +final class BarcodeTxEncoder { + BarcodeTxEncoder({ + required Iterable transactions, + this.options = const BarcodeTxOptions(), + }) : batch = + BarcodeTxBatch(transactions: _normalizeTransactions(transactions)) { + options.validate(); + serializedBatch = BarcodeTxSerializer.serialize(batch, options); + final digest = barcodeTxDigest(serializedBatch); + transferIdBytes = Uint8List.sublistView(digest, 0, 6); + final protectedPayload = Uint8List(serializedBatch.length + digest.length) + ..setRange(0, serializedBatch.length, serializedBatch) + ..setRange(serializedBatch.length, serializedBatch.length + digest.length, + digest); + _fountain = FountainEncoder( + payload: protectedPayload, + transferId: transferIdBytes, + blockSize: options.sourceBlockBytes, + ); + } + + final BarcodeTxOptions options; + final BarcodeTxBatch batch; + late final Uint8List serializedBatch; + late final Uint8List transferIdBytes; + late final FountainEncoder _fountain; + + String get transferId => bytesToHex(transferIdBytes); + int get sourceBlockCount => _fountain.sourceBlockCount; + int get blockSize => _fountain.blockSize; + + BarcodeTxFrame frameAt(int symbolId) => _fountain.frame(symbolId); + + Stream get frames async* { + var symbolId = 0; + while (true) { + yield frameAt(symbolId); + symbolId = symbolId == 0xffffffff ? 0 : symbolId + 1; + await Future.delayed(options.frameDuration); + } + } + + static List _normalizeTransactions( + Iterable values) { + return values.map((value) { + if (value is BarcodeTxTransaction) { + return value; + } + if (value is Map) { + return BarcodeTxTransaction.fromMap(value); + } + if (value is Map) { + return BarcodeTxTransaction.fromMap(value.cast()); + } + throw ArgumentError.value(value, 'transactions', + 'Expected BarcodeTxTransaction or a transaction map.'); + }).toList(growable: false); + } +} diff --git a/lib/src/exceptions/exceptions.dart b/lib/src/exceptions/exceptions.dart new file mode 100644 index 0000000..26586ab --- /dev/null +++ b/lib/src/exceptions/exceptions.dart @@ -0,0 +1,25 @@ +/// Base class for errors produced by BarcodeTX. +sealed class BarcodeTxException implements Exception { + const BarcodeTxException(this.message); + + final String message; + + @override + String toString() => '$runtimeType: $message'; +} + +final class BarcodeTxValidationException extends BarcodeTxException { + const BarcodeTxValidationException(super.message); +} + +final class BarcodeTxProtocolException extends BarcodeTxException { + const BarcodeTxProtocolException(super.message); +} + +final class BarcodeTxIntegrityException extends BarcodeTxException { + const BarcodeTxIntegrityException(super.message); +} + +final class BarcodeTxLimitException extends BarcodeTxException { + const BarcodeTxLimitException(super.message); +} diff --git a/lib/src/fountain/decoder.dart b/lib/src/fountain/decoder.dart new file mode 100644 index 0000000..e018005 --- /dev/null +++ b/lib/src/fountain/decoder.dart @@ -0,0 +1,108 @@ +import 'dart:typed_data'; + +import '../exceptions/exceptions.dart'; +import '../protocol/frame.dart'; +import 'symbols.dart'; + +final class _Equation { + _Equation(this.coefficients, this.value); + BigInt coefficients; + Uint8List value; +} + +final class FountainDecoder { + FountainDecoder({ + required this.payloadLength, + required this.blockSize, + required this.sourceBlockCount, + required this.transferId, + required this.maxReceivedSymbols, + }); + + final int payloadLength; + final int blockSize; + final int sourceBlockCount; + final Uint8List transferId; + final int maxReceivedSymbols; + final Set _symbolIds = {}; + final Map _rows = {}; + Uint8List? _result; + + int get receivedSymbols => _symbolIds.length; + int get rank => _rows.length; + double get progress => (rank / sourceBlockCount).clamp(0, 1); + bool get isComplete => _result != null; + Uint8List? get result => + _result == null ? null : Uint8List.fromList(_result!); + + void add(BarcodeTxFrame frame) { + if (_result != null || !_symbolIds.add(frame.symbolId)) return; + if (_symbolIds.length > maxReceivedSymbols) { + throw BarcodeTxLimitException( + 'Received symbol count exceeds $maxReceivedSymbols.'); + } + final selection = + selectSourceBlocks(frame.symbolId, sourceBlockCount, transferId); + if (selection.degree != frame.degree) { + throw const BarcodeTxProtocolException( + 'Fountain symbol degree does not match its deterministic ID.'); + } + var coefficients = BigInt.zero; + for (final index in selection.indices) { + coefficients |= BigInt.one << index; + } + var value = Uint8List.fromList(frame.symbol); + + while (coefficients != BigInt.zero) { + final pivot = _lowestSetBit(coefficients); + final existing = _rows[pivot]; + if (existing == null) { + _rows[pivot] = _Equation(coefficients, value); + break; + } + coefficients ^= existing.coefficients; + value = _xor(value, existing.value); + } + if (coefficients == BigInt.zero && value.any((byte) => byte != 0)) { + throw const BarcodeTxIntegrityException('Inconsistent fountain symbol.'); + } + if (_rows.length == sourceBlockCount) _reconstruct(); + } + + void _reconstruct() { + final solved = List.filled(sourceBlockCount, null); + for (var pivot = sourceBlockCount - 1; pivot >= 0; pivot--) { + final equation = _rows[pivot]; + if (equation == null) return; + var value = Uint8List.fromList(equation.value); + for (var index = pivot + 1; index < sourceBlockCount; index++) { + if ((equation.coefficients & (BigInt.one << index)) != BigInt.zero) { + value = _xor(value, solved[index]!); + } + } + solved[pivot] = value; + } + final joined = Uint8List(sourceBlockCount * blockSize); + for (var i = 0; i < solved.length; i++) { + joined.setRange(i * blockSize, (i + 1) * blockSize, solved[i]!); + } + _result = Uint8List.sublistView(joined, 0, payloadLength); + } + + static int _lowestSetBit(BigInt value) { + var index = 0; + while ((value & BigInt.one) == BigInt.zero) { + value >>= 1; + index++; + } + return index; + } + + static Uint8List _xor(Uint8List a, Uint8List b) { + final result = Uint8List(a.length); + for (var i = 0; i < a.length; i++) { + result[i] = a[i] ^ b[i]; + } + return result; + } +} diff --git a/lib/src/fountain/encoder.dart b/lib/src/fountain/encoder.dart new file mode 100644 index 0000000..0019dc6 --- /dev/null +++ b/lib/src/fountain/encoder.dart @@ -0,0 +1,52 @@ +import 'dart:typed_data'; + +import '../exceptions/exceptions.dart'; +import '../protocol/frame.dart'; +import 'symbols.dart'; + +final class FountainEncoder { + FountainEncoder({ + required Uint8List payload, + required this.transferId, + required this.blockSize, + }) : payload = Uint8List.fromList(payload) { + if (blockSize <= 0) throw ArgumentError.value(blockSize, 'blockSize'); + sourceBlockCount = (payload.length + blockSize - 1) ~/ blockSize; + if (sourceBlockCount == 0 || sourceBlockCount > 0xffff) { + throw const BarcodeTxLimitException( + 'Invalid fountain source-block count.'); + } + blocks = List.generate(sourceBlockCount, (index) { + final block = Uint8List(blockSize); + final start = index * blockSize; + final end = start + blockSize < payload.length + ? start + blockSize + : payload.length; + block.setRange(0, end - start, payload, start); + return block; + }, growable: false); + } + + final Uint8List payload; + final Uint8List transferId; + final int blockSize; + late final int sourceBlockCount; + late final List blocks; + + BarcodeTxFrame frame(int symbolId) { + if (symbolId < 0 || symbolId > 0xffffffff) { + throw RangeError.range(symbolId, 0, 0xffffffff, 'symbolId'); + } + final selection = + selectSourceBlocks(symbolId, sourceBlockCount, transferId); + return BarcodeTxFrame( + transferIdBytes: transferId, + payloadLength: payload.length, + blockSize: blockSize, + sourceBlockCount: sourceBlockCount, + symbolId: symbolId, + degree: selection.degree, + symbol: xorBlocks(blocks, selection.indices, blockSize), + ); + } +} diff --git a/lib/src/fountain/symbols.dart b/lib/src/fountain/symbols.dart new file mode 100644 index 0000000..160142b --- /dev/null +++ b/lib/src/fountain/symbols.dart @@ -0,0 +1,44 @@ +import 'dart:typed_data'; + +final class FountainSelection { + const FountainSelection(this.indices, this.degree); + + final List indices; + final int degree; +} + +FountainSelection selectSourceBlocks( + int symbolId, int sourceCount, List transferId) { + if (symbolId < sourceCount) return FountainSelection([symbolId], 1); + var state = (symbolId * 0x9e3779b1) & 0xffffffff; + for (var i = 0; i < transferId.length; i++) { + state = (state ^ (transferId[i] << ((i & 3) * 8))) & 0xffffffff; + } + + int next() { + state ^= (state << 13) & 0xffffffff; + state ^= state >>> 17; + state ^= (state << 5) & 0xffffffff; + return state & 0xffffffff; + } + + final maximumDegree = sourceCount < 8 ? sourceCount : 8; + final degree = sourceCount == 1 ? 1 : 2 + (next() % (maximumDegree - 1)); + final selected = {}; + while (selected.length < degree) { + selected.add(next() % sourceCount); + } + final indices = selected.toList()..sort(); + return FountainSelection(indices, degree); +} + +Uint8List xorBlocks(List blocks, List indices, int blockSize) { + final output = Uint8List(blockSize); + for (final index in indices) { + final block = blocks[index]; + for (var i = 0; i < blockSize; i++) { + output[i] ^= block[i]; + } + } + return output; +} diff --git a/lib/src/models/batch.dart b/lib/src/models/batch.dart new file mode 100644 index 0000000..174732d --- /dev/null +++ b/lib/src/models/batch.dart @@ -0,0 +1,14 @@ +import 'transaction.dart'; + +final class BarcodeTxBatch { + BarcodeTxBatch( + {this.version = 1, required List transactions}) + : transactions = List.unmodifiable(transactions); + + final int version; + final List transactions; + + List> toMaps() => transactions + .map((transaction) => transaction.toMap()) + .toList(growable: false); +} diff --git a/lib/src/models/options.dart b/lib/src/models/options.dart new file mode 100644 index 0000000..69ea491 --- /dev/null +++ b/lib/src/models/options.dart @@ -0,0 +1,55 @@ +final class BarcodeTxOptions { + const BarcodeTxOptions({ + this.qrSize = 320, + this.frameDuration = const Duration(milliseconds: 150), + this.errorCorrection = 0, + this.maxQrPayloadBytes = 800, + this.maxBatchBytes = 1024 * 1024, + this.maxTransactionBytes = 512 * 1024, + this.maxTransactions = 256, + this.maxTypeBytes = 32, + this.maxReceivedSymbols = 8192, + }); + + final double qrSize; + final Duration frameDuration; + final int errorCorrection; + final int maxQrPayloadBytes; + final int maxBatchBytes; + final int maxTransactionBytes; + final int maxTransactions; + final int maxTypeBytes; + final int maxReceivedSymbols; + + static const int frameOverhead = 27; + + int get sourceBlockBytes => maxQrPayloadBytes - frameOverhead; + + void validate() { + if (qrSize <= 0 || frameDuration <= Duration.zero) { + throw ArgumentError('QR size and frame duration must be positive.'); + } + const qrByteCapacities = {0: 2331, 1: 2953, 2: 1273, 3: 1663}; + final qrCapacity = qrByteCapacities[errorCorrection]; + if (qrCapacity == null) { + throw ArgumentError.value(errorCorrection, 'errorCorrection', + 'Expected a qr package error-correction constant.'); + } + if (maxQrPayloadBytes <= frameOverhead || maxQrPayloadBytes > qrCapacity) { + throw ArgumentError( + 'maxQrPayloadBytes must be 28..$qrCapacity at the selected QR error-correction level.'); + } + if (maxBatchBytes <= 0 || + maxBatchBytes > 0xffffffff - 32 || + maxTransactionBytes <= 0 || + maxTransactionBytes > 0xffffffff || + maxTransactions <= 0 || + maxTransactions > 0xffff || + maxTypeBytes <= 0 || + maxTypeBytes > 0xff || + maxReceivedSymbols <= 0) { + throw ArgumentError( + 'Protocol limits are outside their wire-format ranges.'); + } + } +} diff --git a/lib/src/models/transaction.dart b/lib/src/models/transaction.dart new file mode 100644 index 0000000..e7d51fa --- /dev/null +++ b/lib/src/models/transaction.dart @@ -0,0 +1,84 @@ +import 'dart:typed_data'; + +import '../exceptions/exceptions.dart'; + +/// An opaque, already-signed blockchain transaction and its caller-supplied type. +final class BarcodeTxTransaction { + BarcodeTxTransaction({required String type, required String hex}) + : type = _normalizeType(type), + hex = _normalizeHex(hex); + + final String type; + final String hex; + + Uint8List get bytes { + final result = Uint8List(hex.length ~/ 2); + for (var i = 0; i < result.length; i++) { + result[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16); + } + return result; + } + + Map toMap() => {'type': type, 'hex': hex}; + + factory BarcodeTxTransaction.fromMap(Map value) { + final type = value['type']; + final hex = value['hex']; + if (type is! String || hex is! String) { + throw const BarcodeTxValidationException( + 'Transaction maps require String "type" and "hex" values.', + ); + } + return BarcodeTxTransaction(type: type, hex: hex); + } + + static BarcodeTxTransaction fromBytes(String type, List bytes) => + BarcodeTxTransaction( + type: type, + hex: bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(), + ); + + static String _normalizeType(String value) { + final normalized = value.toUpperCase(); + if (normalized.isEmpty) { + throw const BarcodeTxValidationException('Transaction type is empty.'); + } + if (!RegExp(r'^[A-Z0-9][A-Z0-9._+-]*$').hasMatch(normalized)) { + throw const BarcodeTxValidationException( + 'Transaction type must be an ASCII identifier containing only A-Z, 0-9, dot, underscore, plus, or hyphen.', + ); + } + return normalized; + } + + static String _normalizeHex(String value) { + var normalized = value; + if (normalized.startsWith('0x') || normalized.startsWith('0X')) { + normalized = normalized.substring(2); + } + if (normalized.isEmpty) { + throw const BarcodeTxValidationException('Transaction hex is empty.'); + } + if (normalized.length.isOdd) { + throw const BarcodeTxValidationException( + 'Transaction hex must contain an even number of characters.', + ); + } + if (!RegExp(r'^[0-9A-Fa-f]+$').hasMatch(normalized)) { + throw const BarcodeTxValidationException( + 'Transaction hex contains a non-hexadecimal character.', + ); + } + return normalized.toUpperCase(); + } + + @override + bool operator ==(Object other) => + other is BarcodeTxTransaction && other.type == type && other.hex == hex; + + @override + int get hashCode => Object.hash(type, hex); + + @override + String toString() => 'BarcodeTxTransaction(type: $type, hex: $hex)'; +} diff --git a/lib/src/protocol/bytes.dart b/lib/src/protocol/bytes.dart new file mode 100644 index 0000000..f9e8d58 --- /dev/null +++ b/lib/src/protocol/bytes.dart @@ -0,0 +1,31 @@ +import 'dart:typed_data'; + +int readUint16(Uint8List bytes, int offset) => + (ByteData.sublistView(bytes, offset, offset + 2)).getUint16(0); + +int readUint32(Uint8List bytes, int offset) => + (ByteData.sublistView(bytes, offset, offset + 4)).getUint32(0); + +void writeUint16(BytesBuilder builder, int value) { + final bytes = ByteData(2)..setUint16(0, value); + builder.add(bytes.buffer.asUint8List()); +} + +void writeUint32(BytesBuilder builder, int value) { + final bytes = ByteData(4)..setUint32(0, value); + builder.add(bytes.buffer.asUint8List()); +} + +bool constantTimeEquals(List a, List b) { + if (a.length != b.length) return false; + var difference = 0; + for (var i = 0; i < a.length; i++) { + difference |= a[i] ^ b[i]; + } + return difference == 0; +} + +String bytesToHex(List bytes) => bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join() + .toUpperCase(); diff --git a/lib/src/protocol/crc32.dart b/lib/src/protocol/crc32.dart new file mode 100644 index 0000000..323a0c8 --- /dev/null +++ b/lib/src/protocol/crc32.dart @@ -0,0 +1,10 @@ +int barcodeTxCrc32(List bytes) { + var crc = 0xffffffff; + for (final byte in bytes) { + crc ^= byte; + for (var bit = 0; bit < 8; bit++) { + crc = (crc & 1) != 0 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + } + } + return (crc ^ 0xffffffff) & 0xffffffff; +} diff --git a/lib/src/protocol/frame.dart b/lib/src/protocol/frame.dart new file mode 100644 index 0000000..103cb5d --- /dev/null +++ b/lib/src/protocol/frame.dart @@ -0,0 +1,104 @@ +import 'dart:typed_data'; + +import '../exceptions/exceptions.dart'; +import '../models/options.dart'; +import 'bytes.dart'; +import 'crc32.dart'; + +final class BarcodeTxFrame { + const BarcodeTxFrame({ + required this.transferIdBytes, + required this.payloadLength, + required this.blockSize, + required this.sourceBlockCount, + required this.symbolId, + required this.degree, + required this.symbol, + }); + + static const List magic = [0x42, 0x58]; // BX + static const int version = 1; + + final Uint8List transferIdBytes; + final int payloadLength; + final int blockSize; + final int sourceBlockCount; + final int symbolId; + final int degree; + final Uint8List symbol; + + Uint8List get bytes { + final builder = BytesBuilder(copy: false) + ..add(magic) + ..addByte(version) + ..addByte(0) + ..add(transferIdBytes); + writeUint32(builder, payloadLength); + writeUint16(builder, blockSize); + writeUint16(builder, sourceBlockCount); + writeUint32(builder, symbolId); + builder.addByte(degree); + builder.add(symbol); + final withoutCrc = builder.takeBytes(); + final output = BytesBuilder(copy: false)..add(withoutCrc); + writeUint32(output, barcodeTxCrc32(withoutCrc)); + return output.takeBytes(); + } + + static BarcodeTxFrame parse(Uint8List bytes, BarcodeTxOptions options) { + if (bytes.length < BarcodeTxOptions.frameOverhead || + bytes.length > options.maxQrPayloadBytes) { + throw const BarcodeTxProtocolException('Invalid BarcodeTX frame size.'); + } + if (bytes[0] != magic[0] || bytes[1] != magic[1]) { + throw const BarcodeTxProtocolException('Not a BarcodeTX frame.'); + } + if (bytes[2] != version) { + throw BarcodeTxProtocolException( + 'Unsupported frame version ${bytes[2]}.'); + } + if (bytes[3] != 0) { + throw const BarcodeTxProtocolException('Unsupported frame flags.'); + } + final expectedCrc = readUint32(bytes, bytes.length - 4); + final actualCrc = + barcodeTxCrc32(Uint8List.sublistView(bytes, 0, bytes.length - 4)); + if (actualCrc != expectedCrc) { + throw const BarcodeTxIntegrityException('Frame CRC-32 mismatch.'); + } + + final payloadLength = readUint32(bytes, 10); + final blockSize = readUint16(bytes, 14); + final sourceCount = readUint16(bytes, 16); + final symbolId = readUint32(bytes, 18); + final degree = bytes[22]; + if (payloadLength == 0 || payloadLength > options.maxBatchBytes + 32) { + throw const BarcodeTxLimitException( + 'Frame declares an excessive payload length.'); + } + if (blockSize == 0 || + blockSize > options.sourceBlockBytes || + bytes.length != BarcodeTxOptions.frameOverhead + blockSize) { + throw const BarcodeTxProtocolException('Invalid fountain block size.'); + } + final expectedCount = (payloadLength + blockSize - 1) ~/ blockSize; + if (sourceCount == 0 || + sourceCount != expectedCount || + sourceCount > 0xffff) { + throw const BarcodeTxProtocolException( + 'Invalid fountain source-block count.'); + } + if (degree == 0 || degree > sourceCount || degree > 8) { + throw const BarcodeTxProtocolException('Invalid fountain symbol degree.'); + } + return BarcodeTxFrame( + transferIdBytes: Uint8List.sublistView(bytes, 4, 10), + payloadLength: payloadLength, + blockSize: blockSize, + sourceBlockCount: sourceCount, + symbolId: symbolId, + degree: degree, + symbol: Uint8List.sublistView(bytes, 23, bytes.length - 4), + ); + } +} diff --git a/lib/src/protocol/integrity.dart b/lib/src/protocol/integrity.dart new file mode 100644 index 0000000..62b304a --- /dev/null +++ b/lib/src/protocol/integrity.dart @@ -0,0 +1,9 @@ +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +Uint8List barcodeTxDigest(List bytes) => + Uint8List.fromList(sha256.convert(bytes).bytes); + +Uint8List barcodeTxTransferId(List canonicalBatch) => + Uint8List.sublistView(barcodeTxDigest(canonicalBatch), 0, 6); diff --git a/lib/src/protocol/serializer.dart b/lib/src/protocol/serializer.dart new file mode 100644 index 0000000..bccd373 --- /dev/null +++ b/lib/src/protocol/serializer.dart @@ -0,0 +1,113 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import '../exceptions/exceptions.dart'; +import '../models/batch.dart'; +import '../models/options.dart'; +import '../models/transaction.dart'; +import 'bytes.dart'; + +final class BarcodeTxSerializer { + static const List magic = [0x42, 0x54, 0x58, 0x42]; // BTXB + static const int version = 1; + + static Uint8List serialize(BarcodeTxBatch batch, BarcodeTxOptions options) { + options.validate(); + if (batch.version != version) { + throw BarcodeTxProtocolException( + 'Unsupported batch version ${batch.version}.'); + } + if (batch.transactions.isEmpty) { + throw const BarcodeTxValidationException( + 'A batch requires at least one transaction.'); + } + if (batch.transactions.length > options.maxTransactions || + batch.transactions.length > 0xffff) { + throw BarcodeTxLimitException( + 'Transaction count exceeds ${options.maxTransactions}.'); + } + final builder = BytesBuilder(copy: false) + ..add(magic) + ..addByte(version); + writeUint16(builder, batch.transactions.length); + for (final transaction in batch.transactions) { + final type = ascii.encode(transaction.type); + final payload = transaction.bytes; + if (type.length > options.maxTypeBytes || type.length > 0xff) { + throw BarcodeTxLimitException( + 'Transaction type exceeds ${options.maxTypeBytes} bytes.'); + } + if (payload.length > options.maxTransactionBytes) { + throw BarcodeTxLimitException( + 'Transaction exceeds ${options.maxTransactionBytes} bytes.'); + } + builder.addByte(type.length); + builder.add(type); + writeUint32(builder, payload.length); + builder.add(payload); + } + final result = builder.takeBytes(); + if (result.length > options.maxBatchBytes) { + throw BarcodeTxLimitException( + 'Serialized batch exceeds ${options.maxBatchBytes} bytes.'); + } + return result; + } + + static BarcodeTxBatch deserialize(Uint8List bytes, BarcodeTxOptions options) { + if (bytes.length < 7 || bytes.length > options.maxBatchBytes) { + throw const BarcodeTxProtocolException('Invalid batch length.'); + } + for (var i = 0; i < magic.length; i++) { + if (bytes[i] != magic[i]) { + throw const BarcodeTxProtocolException('Invalid batch magic.'); + } + } + final batchVersion = bytes[4]; + if (batchVersion != version) { + throw BarcodeTxProtocolException( + 'Unsupported batch version $batchVersion.'); + } + final count = readUint16(bytes, 5); + if (count == 0 || count > options.maxTransactions) { + throw const BarcodeTxProtocolException('Invalid transaction count.'); + } + var offset = 7; + final transactions = []; + for (var index = 0; index < count; index++) { + if (offset >= bytes.length) { + throw const BarcodeTxProtocolException('Truncated transaction type.'); + } + final typeLength = bytes[offset++]; + if (typeLength == 0 || + typeLength > options.maxTypeBytes || + offset + typeLength + 4 > bytes.length) { + throw const BarcodeTxProtocolException( + 'Invalid transaction type length.'); + } + String type; + try { + type = ascii.decode(bytes.sublist(offset, offset + typeLength)); + } on FormatException { + throw const BarcodeTxProtocolException( + 'Transaction type is not ASCII.'); + } + offset += typeLength; + final payloadLength = readUint32(bytes, offset); + offset += 4; + if (payloadLength == 0 || + payloadLength > options.maxTransactionBytes || + payloadLength > bytes.length - offset) { + throw const BarcodeTxProtocolException( + 'Invalid transaction payload length.'); + } + transactions.add(BarcodeTxTransaction.fromBytes( + type, bytes.sublist(offset, offset + payloadLength))); + offset += payloadLength; + } + if (offset != bytes.length) { + throw const BarcodeTxProtocolException('Trailing bytes after batch.'); + } + return BarcodeTxBatch(version: batchVersion, transactions: transactions); + } +} diff --git a/lib/src/qr/animated_qr.dart b/lib/src/qr/animated_qr.dart new file mode 100644 index 0000000..4c5f4b8 --- /dev/null +++ b/lib/src/qr/animated_qr.dart @@ -0,0 +1,87 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/widgets.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import '../encoder/barcode_tx_encoder.dart'; +import '../models/options.dart'; + +/// Displays the unbounded BarcodeTX fountain stream as binary QR symbols. +final class BarcodeTxAnimatedQr extends StatefulWidget { + const BarcodeTxAnimatedQr({ + super.key, + required this.transactions, + this.options = const BarcodeTxOptions(), + this.size, + this.frameDuration, + this.semanticsLabel = 'Animated BarcodeTX transaction QR code', + }); + + final Iterable transactions; + final BarcodeTxOptions options; + final double? size; + final Duration? frameDuration; + final String semanticsLabel; + + @override + State createState() => _BarcodeTxAnimatedQrState(); +} + +final class _BarcodeTxAnimatedQrState extends State { + late BarcodeTxEncoder _encoder; + late Uint8List _bytes; + Timer? _timer; + var _symbolId = 0; + + @override + void initState() { + super.initState(); + _start(); + } + + @override + void didUpdateWidget(BarcodeTxAnimatedQr oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.transactions != widget.transactions || + oldWidget.options != widget.options || + oldWidget.frameDuration != widget.frameDuration) { + _start(); + } + } + + void _start() { + _timer?.cancel(); + _symbolId = 0; + _encoder = BarcodeTxEncoder( + transactions: widget.transactions, options: widget.options); + _bytes = _encoder.frameAt(_symbolId).bytes; + _timer = Timer.periodic( + widget.frameDuration ?? widget.options.frameDuration, (_) { + if (!mounted) return; + setState(() { + _symbolId = _symbolId == 0xffffffff ? 0 : _symbolId + 1; + _bytes = _encoder.frameAt(_symbolId).bytes; + }); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final qr = QrCode.fromUint8List( + data: _bytes, + errorCorrectLevel: widget.options.errorCorrection, + ); + return QrImageView.withQr( + qr: qr, + size: widget.size ?? widget.options.qrSize, + semanticsLabel: widget.semanticsLabel, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..5a82e74 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,41 @@ +name: flutter_barcodetx +description: Animated QR transport for already-signed blockchain transactions using deterministic fountain erasure coding. +version: 0.1.0 +homepage: https://github.com/DataLayerHost/flutter_barcodetx +repository: https://github.com/DataLayerHost/flutter_barcodetx +documentation: https://github.com/DataLayerHost/flutter_barcodetx#readme +issue_tracker: https://github.com/DataLayerHost/flutter_barcodetx/issues + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.29.1" + +dependencies: + crypto: ^3.0.7 + flutter: + sdk: flutter + qr_flutter: ^4.1.0 + +dev_dependencies: + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter + +topics: + - blockchain + - qr-code + - fountain-code + - transaction + - offline + +funding: + - https://github.com/sponsors/bchainhub + - https://blockindex.net/address/cb7147879011ea207df5b35a24ca6f0859dcfb145999 + +platforms: + android: + ios: + linux: + macos: + web: + windows: diff --git a/test/flutter_barcodetx_test.dart b/test/flutter_barcodetx_test.dart new file mode 100644 index 0000000..c10f5a7 --- /dev/null +++ b/test/flutter_barcodetx_test.dart @@ -0,0 +1,230 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter_barcodetx/flutter_barcodetx.dart'; +import 'package:flutter_barcodetx/src/protocol/serializer.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('normalization and validation', () { + test('normalizes lowercase type, hex, and optional prefix', () { + final transaction = BarcodeTxTransaction(type: 'xcb-test', hex: '0xabcD'); + expect(transaction.type, 'XCB-TEST'); + expect(transaction.hex, 'ABCD'); + expect(transaction.bytes, [0xab, 0xcd]); + expect(transaction.toMap(), {'type': 'XCB-TEST', 'hex': 'ABCD'}); + }); + + test('accepts map input', () { + final encoder = BarcodeTxEncoder(transactions: [ + {'type': 'btc', 'hex': '02000000'}, + ]); + expect(encoder.batch.transactions.single.type, 'BTC'); + }); + + test('rejects malformed inputs with typed exceptions', () { + expect(() => BarcodeTxTransaction(type: '', hex: 'AA'), + throwsA(isA())); + expect(() => BarcodeTxTransaction(type: 'ETH!', hex: 'AA'), + throwsA(isA())); + expect(() => BarcodeTxTransaction(type: 'ETH', hex: ''), + throwsA(isA())); + expect(() => BarcodeTxTransaction(type: 'ETH', hex: 'GG'), + throwsA(isA())); + expect(() => BarcodeTxTransaction(type: 'ETH', hex: 'ABC'), + throwsA(isA())); + }); + }); + + group('round trips', () { + test('single XCB transaction', () { + _roundTrip([BarcodeTxTransaction(type: 'XCB', hex: '0xABCD')]); + }); + + test('single ETH transaction', () { + _roundTrip([BarcodeTxTransaction(type: 'eth', hex: 'f86c0185')]); + }); + + test('mixed transactions preserve order', () { + _roundTrip([ + BarcodeTxTransaction(type: 'xcb', hex: 'AABB'), + BarcodeTxTransaction(type: 'btc', hex: '02000000'), + BarcodeTxTransaction(type: 'sol', hex: '010203040506'), + ]); + }); + + test('large transaction needs and recovers from many symbols', () { + final hex = List.generate( + 5000, (index) => (index & 0xff).toRadixString(16).padLeft(2, '0')) + .join(); + final options = const BarcodeTxOptions(maxQrPayloadBytes: 96); + final transaction = BarcodeTxTransaction(type: 'DOGE', hex: hex); + final encoder = + BarcodeTxEncoder(transactions: [transaction], options: options); + expect(encoder.sourceBlockCount, greaterThan(50)); + final decoder = BarcodeTxDecoder(options: options); + for (var id = 0; !decoder.isComplete && id < 1000; id++) { + decoder.addFrame(encoder.frameAt(id).bytes); + } + expect(decoder.isComplete, isTrue); + expect(decoder.transactions, [transaction]); + }); + + test('random drops, shuffle, duplicates, and parity recover', () { + for (var iteration = 0; iteration < 10; iteration++) { + final random = Random(0x425458 + iteration); + final bytes = List.generate(1400, (_) => random.nextInt(256)); + final transaction = + BarcodeTxTransaction.fromBytes('CHAIN_$iteration', bytes); + final options = const BarcodeTxOptions(maxQrPayloadBytes: 96); + final encoder = + BarcodeTxEncoder(transactions: [transaction], options: options); + final ids = []; + for (var id = 0; id < encoder.sourceBlockCount * 4; id++) { + if (random.nextDouble() >= 0.35) ids.add(id); + } + ids + ..addAll(ids.take(5)) + ..shuffle(random); + final decoder = BarcodeTxDecoder(options: options); + for (final id in ids) { + decoder.addFrame(encoder.frameAt(id).bytes); + if (decoder.isComplete) break; + } + expect(decoder.isComplete, isTrue, reason: 'iteration $iteration'); + expect(decoder.transactions.single, transaction); + } + }); + }); + + group('protocol and integrity', () { + test('serialization and transfer ID are deterministic', () { + final transactions = [BarcodeTxTransaction(type: 'xcb', hex: 'AABBCCDD')]; + final first = BarcodeTxEncoder(transactions: transactions); + final second = BarcodeTxEncoder(transactions: transactions); + expect(first.serializedBatch, second.serializedBatch); + expect(first.transferId, second.transferId); + expect(first.frameAt(8).bytes, second.frameAt(8).bytes); + }); + + test('different batches have different IDs', () { + final first = BarcodeTxEncoder( + transactions: [BarcodeTxTransaction(type: 'XCB', hex: 'AA')]); + final second = BarcodeTxEncoder( + transactions: [BarcodeTxTransaction(type: 'XCB', hex: 'AB')]); + expect(first.transferId, isNot(second.transferId)); + }); + + test('mixed transfers are ignored after the session is selected', () { + final first = BarcodeTxEncoder(transactions: [ + BarcodeTxTransaction(type: 'XCB', hex: List.filled(1000, 'AA').join()) + ]); + final second = BarcodeTxEncoder(transactions: [ + BarcodeTxTransaction(type: 'BTC', hex: List.filled(1000, 'BB').join()) + ]); + final decoder = BarcodeTxDecoder(); + expect(decoder.addFrame(first.frameAt(0).bytes).accepted, isTrue); + expect(decoder.addFrame(second.frameAt(0).bytes).accepted, isFalse); + for (var id = 1; !decoder.isComplete; id++) { + decoder.addFrame(first.frameAt(id).bytes); + } + expect(decoder.transactions.single.type, 'XCB'); + }); + + test('rejects corrupted frame', () { + final encoder = BarcodeTxEncoder( + transactions: [BarcodeTxTransaction(type: 'XCB', hex: 'AABB')]); + final bytes = Uint8List.fromList(encoder.frameAt(0).bytes)..[23] ^= 1; + expect(() => BarcodeTxDecoder().addFrame(bytes), + throwsA(isA())); + }); + + test('rejects corrupted reconstructed payload', () { + final encoder = BarcodeTxEncoder( + transactions: [ + BarcodeTxTransaction(type: 'XCB', hex: List.filled(100, 'AA').join()) + ], + options: const BarcodeTxOptions(maxQrPayloadBytes: 64), + ); + final decoder = BarcodeTxDecoder(options: encoder.options); + for (var id = 0; id < encoder.sourceBlockCount; id++) { + var frame = encoder.frameAt(id); + if (id == 0) { + final symbol = Uint8List.fromList(frame.symbol)..[0] ^= 1; + frame = BarcodeTxFrame( + transferIdBytes: frame.transferIdBytes, + payloadLength: frame.payloadLength, + blockSize: frame.blockSize, + sourceBlockCount: frame.sourceBlockCount, + symbolId: frame.symbolId, + degree: frame.degree, + symbol: symbol, + ); + } + if (id == encoder.sourceBlockCount - 1) { + expect(() => decoder.addFrame(frame.bytes), + throwsA(isA())); + } else { + decoder.addFrame(frame.bytes); + } + } + }); + + test('rejects unknown frame version', () { + final encoder = BarcodeTxEncoder( + transactions: [BarcodeTxTransaction(type: 'XCB', hex: 'AA')]); + final bytes = Uint8List.fromList(encoder.frameAt(0).bytes)..[2] = 99; + expect(() => BarcodeTxDecoder().addFrame(bytes), + throwsA(isA())); + }); + + test('rejects oversized declared payload before allocation', () { + final options = const BarcodeTxOptions(maxBatchBytes: 100); + final frame = BarcodeTxFrame( + transferIdBytes: Uint8List(6), + payloadLength: 133, + blockSize: 1, + sourceBlockCount: 133, + symbolId: 0, + degree: 1, + symbol: Uint8List(1), + ); + expect(() => BarcodeTxDecoder(options: options).addFrame(frame.bytes), + throwsA(isA())); + }); + + test('batch deserializer rejects oversized internal length', () { + final bytes = Uint8List.fromList([ + ...BarcodeTxSerializer.magic, + 1, + 0, + 1, + 1, + 88, + 0x7f, + 0xff, + 0xff, + 0xff + ]); + expect( + () => + BarcodeTxSerializer.deserialize(bytes, const BarcodeTxOptions()), + throwsA(isA())); + }); + }); +} + +void _roundTrip(List transactions) { + final encoder = BarcodeTxEncoder(transactions: transactions); + final decoder = BarcodeTxDecoder(); + final frames = + List.generate(encoder.sourceBlockCount, encoder.frameAt).reversed; + for (final frame in frames) { + decoder.addFrame(frame.bytes); + decoder.addFrame(frame.bytes); + } + expect(decoder.isComplete, isTrue); + expect(decoder.transactions, transactions); + expect(decoder.transactionMaps, + transactions.map((transaction) => transaction.toMap()).toList()); +} diff --git a/tool/benchmark.dart b/tool/benchmark.dart new file mode 100644 index 0000000..d425247 --- /dev/null +++ b/tool/benchmark.dart @@ -0,0 +1,47 @@ +import 'dart:math'; + +// ignore_for_file: avoid_print + +import 'package:flutter_barcodetx/src/decoder/barcode_tx_decoder.dart'; +import 'package:flutter_barcodetx/src/encoder/barcode_tx_encoder.dart'; +import 'package:flutter_barcodetx/src/models/transaction.dart'; + +void main() { + for (final size in [250, 500, 1024, 5 * 1024, 10 * 1024]) { + final random = Random(size); + final bytes = List.generate(size, (_) => random.nextInt(256)); + final transaction = BarcodeTxTransaction.fromBytes('BENCH', bytes); + + final encodeWatch = Stopwatch()..start(); + final encoder = BarcodeTxEncoder(transactions: [transaction]); + final firstFrameSize = encoder.frameAt(0).bytes.length; + encodeWatch.stop(); + + final decoder = BarcodeTxDecoder(); + final decodeWatch = Stopwatch()..start(); + var generated = 0; + var accepted = 0; + while (!decoder.isComplete) { + // Simulate losing every fifth displayed symbol. + final id = generated++; + if (id % 5 == 0) continue; + decoder.addFrame(encoder.frameAt(id).bytes); + accepted++; + } + decodeWatch.stop(); + + final overhead = + (accepted - encoder.sourceBlockCount) / encoder.sourceBlockCount * 100; + print([ + 'input=$size B', + 'serialized=${encoder.serializedBatch.length} B', + 'blocks=${encoder.sourceBlockCount}', + 'qrFrame=$firstFrameSize B', + 'accepted=$accepted', + 'generated=$generated', + 'fountainOverhead=${overhead.toStringAsFixed(1)}%', + 'encode=${encodeWatch.elapsedMicroseconds} us', + 'decode=${decodeWatch.elapsedMicroseconds} us', + ].join(' | ')); + } +}