From 873837d0f92c488353e7b839d27da389a9fffa7a Mon Sep 17 00:00:00 2001 From: balladyna <91394225+balladyna@users.noreply.github.com> Date: Mon, 10 Mar 2025 12:08:52 +0100 Subject: [PATCH] Feature: Bech32 This feature implements Bech32 encoder and decoder to support BIP 173 and BIP 350 address formats. It adds full support for encoding and decoding Bech32 strings, including HRP validation, checksum verification, and bit conversion logic. This enables SegWit address parsing and generation, ensuring compatibility with Bitcoin and other systems that use Bech32. List of changes: - created seg_wit.dart, to represent SegWit addresses, including HRP, witness version and witness program data - created seg_wit_decoder.dart to parse and validate Bech32-encoded SegWit addresses into structured SegWit objects - created seg_wit_encoder.dart to serialize SegWit objects into valid Bech32-encoded address strings - created bech32.dart to represent HRP and payload with bit conversion - created bech32_decoder.dart to validate and decode Bech32-encoded strings - created bech32_encoder.dart to convert a Bech32 data structure into a valid Bech32-encoded string - created bytes_utils.dart to convert lists of integers between arbitrary bit group sizes (e.g., 8-bit bytes and 5-bit groups) - created bech32_utils.dart to provide helper methods for HRP expansion and polymod checksum computation as required by the Bech32 encoding standard - unrelated with domain: changed the Ubuntu Actions runner image to the latest general availability version 24.04, due to the deprecation of the version 20.04 --- .github/workflows/version_and_tests.yaml | 4 +- lib/codec_utils.dart | 16 +++- lib/src/codecs/bech32/bech32.dart | 31 +++++++ lib/src/codecs/bech32/bech32_codec.dart | 60 ------------ lib/src/codecs/bech32/bech32_constants.dart | 17 ++++ lib/src/codecs/bech32/bech32_decoder.dart | 83 +++++++++++++++++ lib/src/codecs/bech32/bech32_encoder.dart | 62 +++++++++++++ lib/src/codecs/bech32/bech32_pair.dart | 21 ----- lib/src/codecs/bech32/bech32_utils.dart | 31 +++++++ .../exceptions/invalid_bech32_exception.dart | 8 ++ .../invalid_checksum_exception.dart | 8 ++ .../exceptions/invalid_hrp_exception.dart | 8 ++ .../invalid_witness_program_exception.dart | 8 ++ .../invalid_witness_version_exception.dart | 8 ++ lib/src/codecs/bech32/export.dart | 9 +- lib/src/codecs/bech32/seg_wit/seg_wit.dart | 25 +++++ .../bech32/seg_wit/seg_wit_decoder.dart | 70 ++++++++++++++ .../bech32/seg_wit/seg_wit_encoder.dart | 64 +++++++++++++ .../codecs/bech32/segwit_bech32_codec.dart | 19 ---- lib/src/utils/bytes_utils.dart | 41 ++++++++ pubspec.yaml | 6 +- .../unit/codecs/bech32/bech32_codec_test.dart | 37 -------- .../codecs/bech32/bech32_decoder_test.dart | 93 +++++++++++++++++++ .../codecs/bech32/bech32_encoder_test.dart | 50 ++++++++++ .../unit/codecs/bech32/bech32_utils_test.dart | 35 +++++++ .../bech32/seg_wit/seg_wit_decoder_test.dart | 79 ++++++++++++++++ .../bech32/seg_wit/seg_wit_encoder_test.dart | 71 ++++++++++++++ .../bech32/segwit_bech32_codec_test.dart | 37 -------- test/unit/utils/bytes_utils_test.dart | 62 +++++++++++++ 29 files changed, 874 insertions(+), 189 deletions(-) create mode 100644 lib/src/codecs/bech32/bech32.dart delete mode 100644 lib/src/codecs/bech32/bech32_codec.dart create mode 100644 lib/src/codecs/bech32/bech32_constants.dart create mode 100644 lib/src/codecs/bech32/bech32_decoder.dart create mode 100644 lib/src/codecs/bech32/bech32_encoder.dart delete mode 100644 lib/src/codecs/bech32/bech32_pair.dart create mode 100644 lib/src/codecs/bech32/bech32_utils.dart create mode 100644 lib/src/codecs/bech32/exceptions/invalid_bech32_exception.dart create mode 100644 lib/src/codecs/bech32/exceptions/invalid_checksum_exception.dart create mode 100644 lib/src/codecs/bech32/exceptions/invalid_hrp_exception.dart create mode 100644 lib/src/codecs/bech32/exceptions/invalid_witness_program_exception.dart create mode 100644 lib/src/codecs/bech32/exceptions/invalid_witness_version_exception.dart create mode 100644 lib/src/codecs/bech32/seg_wit/seg_wit.dart create mode 100644 lib/src/codecs/bech32/seg_wit/seg_wit_decoder.dart create mode 100644 lib/src/codecs/bech32/seg_wit/seg_wit_encoder.dart delete mode 100644 lib/src/codecs/bech32/segwit_bech32_codec.dart create mode 100644 lib/src/utils/bytes_utils.dart delete mode 100644 test/unit/codecs/bech32/bech32_codec_test.dart create mode 100644 test/unit/codecs/bech32/bech32_decoder_test.dart create mode 100644 test/unit/codecs/bech32/bech32_encoder_test.dart create mode 100644 test/unit/codecs/bech32/bech32_utils_test.dart create mode 100644 test/unit/codecs/bech32/seg_wit/seg_wit_decoder_test.dart create mode 100644 test/unit/codecs/bech32/seg_wit/seg_wit_encoder_test.dart delete mode 100644 test/unit/codecs/bech32/segwit_bech32_codec_test.dart create mode 100644 test/unit/utils/bytes_utils_test.dart diff --git a/.github/workflows/version_and_tests.yaml b/.github/workflows/version_and_tests.yaml index 8bce557..762918b 100644 --- a/.github/workflows/version_and_tests.yaml +++ b/.github/workflows/version_and_tests.yaml @@ -8,7 +8,7 @@ on: jobs: app-version-check: - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: # https://github.com/marketplace/actions/checkout - uses: actions/checkout@main @@ -52,7 +52,7 @@ jobs: exit 1 run-unit-tests: needs: app-version-check - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: # https://github.com/marketplace/actions/checkout - uses: actions/checkout@main diff --git a/lib/codec_utils.dart b/lib/codec_utils.dart index 61dd714..f72f7e5 100644 --- a/lib/codec_utils.dart +++ b/lib/codec_utils.dart @@ -11,12 +11,18 @@ export 'src/codecs/base/base58_codec.dart'; /// Classes designed for encoding data using the Bech32 encoding scheme. /// Usage: -/// ``` -/// String encodedBech32 = Bech32Codec.encode(Bech32Pair(hrp: 'crypto', data: base64Decode('KxmiVli7oFEs8N5rjnzLtw7eym0='))); -/// Bech32Pair decodedBech32 = Bech32Codec.decode("crypto19vv6y4jchws9zt8sme4culxtku8dajndgyhdm2"); +/// `` +/// List convertedUint5List = BytesUtils.convertBits(Bech32.uint8List, 8, 5, padBool: true); +/// +/// Bech32 bech32 = Bech32.fromUint5List('bc', convertedUint5List) +/// String encodedBech32 = Bech32Encoder().encode(bech32); +/// +/// Bech32 decodedBech32 = Bech32Codec().decode("crypto19vv6y4jchws9zt8sme4culxtku8dajndgyhdm2"); +/// +/// SegWit segWit = SegWit(hrp, witnessVersion, witnessProgramUint8List); +/// String encodedSegWit SegWitEncoder().encode(segWit); /// -/// String encodedSegwit = SegwitBech32Codec.encode('bc', 0, base64Decode('KxmiVli7oFEs8N5rjnzLtw7eym0=')); -/// Uint8List decodedSegwit = SegwitBech32Codec.decode("bc1q9vv6y4jchws9zt8sme4culxtku8dajnd5jq660"); +/// SegWit decodedSegWit = SegWitDecoder().decode("bc1q9vv6y4jchws9zt8sme4culxtku8dajnd5jq660"); /// ``` export 'src/codecs/bech32/export.dart'; diff --git a/lib/src/codecs/bech32/bech32.dart b/lib/src/codecs/bech32/bech32.dart new file mode 100644 index 0000000..0ecc643 --- /dev/null +++ b/lib/src/codecs/bech32/bech32.dart @@ -0,0 +1,31 @@ +import 'dart:typed_data'; + +import 'package:codec_utils/src/utils/bytes_utils.dart'; +import 'package:equatable/equatable.dart'; + +/// Class representing a Bech32 encoded pair +/// +/// A Bech32 string consists of two main parts: +/// - The human-readable part (HRP), which indicates the network or context. +/// - The data payload encoded using a 5-bit scheme. +class Bech32 extends Equatable { + /// The human-readable part (hrp) of the Bech32 encoded pair. + final String hrp; + + /// The data part of the Bech32 encoded pair. + final Uint8List uint8List; + + const Bech32(this.hrp, this.uint8List); + + factory Bech32.fromUint5List(String hrp, List uint5List) { + Uint8List uint8List = BytesUtils.convertBits(uint5List, 5, 8); + return Bech32(hrp, uint8List); + } + + List get uint5List { + return BytesUtils.convertBits(uint8List, 8, 5, allowPaddingBool: false); + } + + @override + List get props => [hrp, uint8List]; +} diff --git a/lib/src/codecs/bech32/bech32_codec.dart b/lib/src/codecs/bech32/bech32_codec.dart deleted file mode 100644 index b7750c2..0000000 --- a/lib/src/codecs/bech32/bech32_codec.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'dart:typed_data'; - -import 'package:bech32/bech32.dart'; -import 'package:codec_utils/src/codecs/bech32/bech32_pair.dart'; - -/// The [Bech32Codec] class is designed for encoding data using the Bech32 encoding scheme. -/// The specification for Bech32 encoding can be found in BIP-0173 and BIP-0350: -/// https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki -/// https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki -class Bech32Codec { - static String encode(Bech32Pair bech32pair) { - Uint8List convertedData = _convertBits(bech32pair.data, 8, 5); - // TODO(dominik): Implement custom Bech32 encoding - return bech32.encode(Bech32(bech32pair.hrp, convertedData)); - } - - static Bech32Pair decode(String bechAddress) { - Bech32 decodedBech32 = bech32.decode(bechAddress); - // TODO(dominik): Implement custom Bech32 decoding - Uint8List convertedData = _convertBits(decodedBech32.data, 5, 8, padBool: false); - - return Bech32Pair(data: convertedData, hrp: decodedBech32.hrp); - } - - static Uint8List _convertBits( - List data, - int startBitIndex, - int endBitIndex, { - bool padBool = true, - }) { - int acc = 0; - int bits = 0; - List result = []; - int maxV = (1 << endBitIndex) - 1; - - for (int v in data) { - if (v < 0 || (v >> startBitIndex) != 0) { - throw Exception('Got address byte smaller than zero or greater than 2^startBitIndex'); - } - acc = (acc << startBitIndex) | v; - bits += startBitIndex; - while (bits >= endBitIndex) { - bits -= endBitIndex; - result.add((acc >> bits) & maxV); - } - } - - if (padBool) { - if (bits > 0) { - result.add((acc << (endBitIndex - bits)) & maxV); - } - } else if (bits >= startBitIndex) { - throw Exception('Illegal zero padding'); - } else if (((acc << (endBitIndex - bits)) & maxV) != 0) { - throw Exception('Non zero'); - } - - return Uint8List.fromList(result); - } -} diff --git a/lib/src/codecs/bech32/bech32_constants.dart b/lib/src/codecs/bech32/bech32_constants.dart new file mode 100644 index 0000000..4ac02c0 --- /dev/null +++ b/lib/src/codecs/bech32/bech32_constants.dart @@ -0,0 +1,17 @@ +class Bech32Constants { + static const int maxInputLength = 90; + static const int checksumLength = 6; + static const String separator = '1'; + static const List charList = [ + 'q', 'p', 'z', 'r', 'y', '9', 'x', // + '8', 'g', 'f', '2', 't', 'v', 'd', + 'w', '0', 's', '3', 'j', 'n', '5', + '4', 'k', 'h', 'c', 'e', '6', 'm', + 'u', 'a', '7', 'l', + ]; + + static const List generatorList = [ + 0x3b6a57b2, 0x26508e6d, 0x1ea119fa, // + 0x3d4233dd, 0x2a1462b3, + ]; +} diff --git a/lib/src/codecs/bech32/bech32_decoder.dart b/lib/src/codecs/bech32/bech32_decoder.dart new file mode 100644 index 0000000..693606c --- /dev/null +++ b/lib/src/codecs/bech32/bech32_decoder.dart @@ -0,0 +1,83 @@ +// This class was primarily influenced by: +// Copyright 2020 Harm Aarts +// +// 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: +// +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +import 'package:codec_utils/codec_utils.dart'; +import 'package:codec_utils/src/codecs/bech32/bech32_constants.dart'; +import 'package:codec_utils/src/codecs/bech32/bech32_utils.dart'; +import 'package:codec_utils/src/codecs/bech32/exceptions/invalid_bech32_exception.dart'; +import 'package:codec_utils/src/codecs/bech32/exceptions/invalid_checksum_exception.dart'; +import 'package:codec_utils/src/codecs/bech32/exceptions/invalid_hrp_exception.dart'; + +/// A utility class for decoding Bech32-encoded strings into [Bech32] objects. +/// +/// This decoder validates and parses a Bech32 string into its human-readable part (HRP) +/// and data payload, following the specifications described in BIP 173 and BIP 350. +class Bech32Decoder { + Bech32 decode(String bechAddress, [int maxInputLength = Bech32Constants.maxInputLength]) { + if (bechAddress.length > maxInputLength) { + throw InvalidBech32Exception('Bech32 is too long: ${bechAddress.length} > 90'); + } + if (bechAddress.toLowerCase() != bechAddress && bechAddress.toUpperCase() != bechAddress) { + throw InvalidBech32Exception('Bech32 input must be either all lowercase or all uppercase: $bechAddress'); + } + if (bechAddress.lastIndexOf(Bech32Constants.separator) == -1) { + throw InvalidBech32Exception('Bech32 string is missing the required separator between HRP and data'); + } + + int separatorPosition = bechAddress.lastIndexOf(Bech32Constants.separator); + + if (bechAddress.length - separatorPosition - 1 - Bech32Constants.checksumLength < 0) { + throw InvalidChecksumException('Checksum is too short: ${bechAddress.length} < 6'); + } + + if (separatorPosition == 0) { + throw InvalidHrpException('HRP is too short: $separatorPosition'); + } + + String normalizedInput = bechAddress.toLowerCase(); + + String hrp = normalizedInput.substring(0, separatorPosition); + String data = normalizedInput.substring(separatorPosition + 1, bechAddress.length - Bech32Constants.checksumLength); + String checksum = normalizedInput.substring(bechAddress.length - Bech32Constants.checksumLength); + + if (hrp.codeUnits.any((int element) => element < 33 || element > 126)) { + throw InvalidHrpException('HRP contains invalid characters: $hrp'); + } + + List uint5List = data.split('').map((String element) { + return Bech32Constants.charList.indexOf(element); + }).toList(); + + if (uint5List.any((int element) => element == -1)) { + throw InvalidBech32Exception('Bech32 has undefined character: ${data[uint5List.indexOf(-1)]}'); + } + + List checksumUint5List = checksum.split('').map((String element) { + return Bech32Constants.charList.indexOf(element); + }).toList(); + + if (checksumUint5List.any((int element) => element == -1)) { + int invalidIndex = checksumUint5List.indexOf(-1); + throw InvalidChecksumException('Checksum contains undefined character at position: ${checksumUint5List[invalidIndex]}'); + } + + List expandedHrpUint5List = Bech32Utils.expandHrp(hrp); + + if (Bech32Utils.calculatePolymodChecksum(expandedHrpUint5List + (uint5List + checksumUint5List)) != 1) { + throw InvalidChecksumException('Checksum verification failed for input: $bechAddress'); + } + + return Bech32.fromUint5List(hrp, uint5List); + } +} diff --git a/lib/src/codecs/bech32/bech32_encoder.dart b/lib/src/codecs/bech32/bech32_encoder.dart new file mode 100644 index 0000000..68461e1 --- /dev/null +++ b/lib/src/codecs/bech32/bech32_encoder.dart @@ -0,0 +1,62 @@ +// This class was primarily influenced by: +// Copyright 2020 Harm Aarts +// +// 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: +// +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +import 'dart:typed_data'; + +import 'package:codec_utils/codec_utils.dart'; +import 'package:codec_utils/src/codecs/bech32/bech32_constants.dart'; +import 'package:codec_utils/src/codecs/bech32/bech32_utils.dart'; +import 'package:codec_utils/src/codecs/bech32/exceptions/invalid_bech32_exception.dart'; +import 'package:codec_utils/src/codecs/bech32/exceptions/invalid_hrp_exception.dart'; + +/// A utility class for encoding [Bech32] objects into valid Bech32 strings. +/// +/// This encoder converts a [Bech32] instance (which contains a human-readable part (HRP) +/// and a data payload) into a properly formatted Bech32 string, as specified in +/// BIP 173 and BIP 350. +class Bech32Encoder { + /// Encodes a [Bech32] object [bech32] into a valid Bech32 string. + String encode(Bech32 bech32, [int maxLength = Bech32Constants.maxInputLength]) { + String hrp = bech32.hrp.toLowerCase(); + List uint5List = bech32.uint5List; + int length = hrp.length + uint5List.length + Bech32Constants.separator.length + Bech32Constants.checksumLength; + + if (length > maxLength) { + throw InvalidBech32Exception('Bech32 is too long: ${hrp.length + uint5List.length + 1 + Bech32Constants.checksumLength}'); + } + + if (hrp.isEmpty) { + throw InvalidHrpException('HRP is empty'); + } + + List checkSummedListUint5List = uint5List + _createChecksum(hrp, uint5List); + + return hrp + Bech32Constants.separator + checkSummedListUint5List.map((int element) => Bech32Constants.charList[element]).join(); + } + + /// Computes a 6-byte checksum for the given [hrp] and [uint5List], + /// following Bech32 checksum rules (BIP 173). + /// + /// Returns a [Uint8List] representing the 6 checksum bytes. + List _createChecksum(String hrp, List uint5List) { + List payloadUint5List = Bech32Utils.expandHrp(hrp) + uint5List + [0, 0, 0, 0, 0, 0]; + int polymodValue = Bech32Utils.calculatePolymodChecksum(payloadUint5List) ^ 1; + + List checksumUint5List = [0, 0, 0, 0, 0, 0]; + + for (int i = 0; i < checksumUint5List.length; i++) { + checksumUint5List[i] = (polymodValue >> (5 * (5 - i))) & 31; + } + return checksumUint5List; + } +} diff --git a/lib/src/codecs/bech32/bech32_pair.dart b/lib/src/codecs/bech32/bech32_pair.dart deleted file mode 100644 index b241191..0000000 --- a/lib/src/codecs/bech32/bech32_pair.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'dart:typed_data'; - -import 'package:equatable/equatable.dart'; - -/// Class representing a Bech32 encoded pair -class Bech32Pair extends Equatable { - /// The human-readable part (hrp) of the Bech32 encoded pair. - final String hrp; - - /// The data part of the Bech32 encoded pair. - final Uint8List data; - - /// Creates a [Bech32Pair] with the given [hrp] and [data]. - const Bech32Pair({ - required this.hrp, - required this.data, - }); - - @override - List get props => [hrp, data]; -} diff --git a/lib/src/codecs/bech32/bech32_utils.dart b/lib/src/codecs/bech32/bech32_utils.dart new file mode 100644 index 0000000..7269991 --- /dev/null +++ b/lib/src/codecs/bech32/bech32_utils.dart @@ -0,0 +1,31 @@ +import 'package:codec_utils/src/codecs/bech32/bech32_constants.dart'; + +class Bech32Utils { + /// Calculates the polymod checksum value for a list of integers [payloadUint5List]. + static int calculatePolymodChecksum(List payloadUint5List) { + int checksum = 1; + for (int element in payloadUint5List) { + int highBit = checksum >> 25; + checksum = (checksum & 0x1ffffff) << 5 ^ element; + for (int i = 0; i < Bech32Constants.generatorList.length; i++) { + if ((highBit >> i) & 1 == 1) { + checksum ^= Bech32Constants.generatorList[i]; + } + } + } + return checksum; + } + + /// Expands the human-readable part (HRP) string [hrp] into a list of integers. + /// + /// This transformation is required for checksum calculation as defined + /// in BIP 173. The HRP is split into higher and lower 5-bit groups, separated by a zero delimiter. + /// + /// Returns a [resultUint5List] representing the expanded HRP. + static List expandHrp(String hrp) { + List resultUint5List = hrp.codeUnits.map((int element) => element >> 5).toList(); + resultUint5List = resultUint5List + [0]; + resultUint5List = resultUint5List + hrp.codeUnits.map((int element) => element & 31).toList(); + return resultUint5List; + } +} diff --git a/lib/src/codecs/bech32/exceptions/invalid_bech32_exception.dart b/lib/src/codecs/bech32/exceptions/invalid_bech32_exception.dart new file mode 100644 index 0000000..beed847 --- /dev/null +++ b/lib/src/codecs/bech32/exceptions/invalid_bech32_exception.dart @@ -0,0 +1,8 @@ +class InvalidBech32Exception implements Exception { + final String? message; + + InvalidBech32Exception(this.message); + + @override + String toString() => message ?? runtimeType.toString(); +} diff --git a/lib/src/codecs/bech32/exceptions/invalid_checksum_exception.dart b/lib/src/codecs/bech32/exceptions/invalid_checksum_exception.dart new file mode 100644 index 0000000..81f4976 --- /dev/null +++ b/lib/src/codecs/bech32/exceptions/invalid_checksum_exception.dart @@ -0,0 +1,8 @@ +class InvalidChecksumException implements Exception { + final String? message; + + InvalidChecksumException(this.message); + + @override + String toString() => message ?? runtimeType.toString(); +} diff --git a/lib/src/codecs/bech32/exceptions/invalid_hrp_exception.dart b/lib/src/codecs/bech32/exceptions/invalid_hrp_exception.dart new file mode 100644 index 0000000..acf8b3b --- /dev/null +++ b/lib/src/codecs/bech32/exceptions/invalid_hrp_exception.dart @@ -0,0 +1,8 @@ +class InvalidHrpException implements Exception { + final String? message; + + InvalidHrpException(this.message); + + @override + String toString() => message ?? runtimeType.toString(); +} diff --git a/lib/src/codecs/bech32/exceptions/invalid_witness_program_exception.dart b/lib/src/codecs/bech32/exceptions/invalid_witness_program_exception.dart new file mode 100644 index 0000000..ceab659 --- /dev/null +++ b/lib/src/codecs/bech32/exceptions/invalid_witness_program_exception.dart @@ -0,0 +1,8 @@ +class InvalidWitnessProgramException implements Exception { + final String? message; + + InvalidWitnessProgramException(this.message); + + @override + String toString() => message ?? runtimeType.toString(); +} diff --git a/lib/src/codecs/bech32/exceptions/invalid_witness_version_exception.dart b/lib/src/codecs/bech32/exceptions/invalid_witness_version_exception.dart new file mode 100644 index 0000000..0f0c97c --- /dev/null +++ b/lib/src/codecs/bech32/exceptions/invalid_witness_version_exception.dart @@ -0,0 +1,8 @@ +class InvalidWitnessVersionException implements Exception { + final String? message; + + InvalidWitnessVersionException(this.message); + + @override + String toString() => message ?? runtimeType.toString(); +} diff --git a/lib/src/codecs/bech32/export.dart b/lib/src/codecs/bech32/export.dart index cb7490a..48c1c07 100644 --- a/lib/src/codecs/bech32/export.dart +++ b/lib/src/codecs/bech32/export.dart @@ -1,3 +1,6 @@ -export 'bech32_codec.dart'; -export 'bech32_pair.dart'; -export 'segwit_bech32_codec.dart'; +export 'bech32.dart'; +export 'bech32_decoder.dart'; +export 'bech32_encoder.dart'; +export 'seg_wit/seg_wit.dart'; +export 'seg_wit/seg_wit_decoder.dart'; +export 'seg_wit/seg_wit_encoder.dart'; diff --git a/lib/src/codecs/bech32/seg_wit/seg_wit.dart b/lib/src/codecs/bech32/seg_wit/seg_wit.dart new file mode 100644 index 0000000..246e9cd --- /dev/null +++ b/lib/src/codecs/bech32/seg_wit/seg_wit.dart @@ -0,0 +1,25 @@ +import 'dart:typed_data'; + +import 'package:equatable/equatable.dart'; + +/// A class representing a Segregated Witness (SegWit) address. +/// +/// This class holds the human-readable part (HRP), witness version, +/// and witness program list as defined in BIP 173. +/// +/// You can use this class to represent or validate SegWit addresses in Bech32 encoding. +class SegWit extends Equatable { + /// The human-readable part ("bc" for Bitcoin mainnet). + final String hrp; + + /// The witness version (0–16). + final int witnessVersion; + + /// The witness program data as a list of integers. + final Uint8List witnessProgramUint8List; + + const SegWit(this.hrp, this.witnessVersion, this.witnessProgramUint8List); + + @override + List get props => [hrp, witnessVersion, witnessProgramUint8List]; +} diff --git a/lib/src/codecs/bech32/seg_wit/seg_wit_decoder.dart b/lib/src/codecs/bech32/seg_wit/seg_wit_decoder.dart new file mode 100644 index 0000000..551990c --- /dev/null +++ b/lib/src/codecs/bech32/seg_wit/seg_wit_decoder.dart @@ -0,0 +1,70 @@ +// This class was primarily influenced by: +// Copyright 2020 Harm Aarts +// +// 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: +// +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +import 'dart:typed_data'; + +import 'package:codec_utils/codec_utils.dart'; +import 'package:codec_utils/src/codecs/bech32/exceptions/invalid_hrp_exception.dart'; +import 'package:codec_utils/src/codecs/bech32/exceptions/invalid_witness_program_exception.dart'; +import 'package:codec_utils/src/codecs/bech32/exceptions/invalid_witness_version_exception.dart'; +import 'package:codec_utils/src/utils/bytes_utils.dart'; + +/// A utility class for decoding Segregated Witness (SegWit) addresses encoded in Bech32 format. +/// +/// The [SegWitDecoder] class provides method to parse a Bech32-encoded string +/// and extract its SegWit-specific fields: human-readable part (HRP), witness version, +/// and witness program (programList). +/// +/// The decoder performs steps to ensure the address conforms to +/// BIP 173 and BIP 350 specifications: +/// +/// - Verifies that the HRP is supported "bc" for mainnet, "tb" for testnet). +/// - Checks that the witness version is within the valid range (0–16). +/// - Converts the data part from 5-bit groups (Bech32) to 8-bit groups (program data). +/// - Validates that the witness program has an allowed length and structure depending on its version. +/// +class SegWitDecoder { + SegWit decode(String input) { + Bech32 decodedBech32 = Bech32Decoder().decode(input); + + List convertedUint5List = BytesUtils.convertBits(decodedBech32.uint8List, 8, 5); + + if (decodedBech32.hrp != 'bc' && decodedBech32.hrp != 'tb') { + throw InvalidHrpException('Invalid hrp ${decodedBech32.hrp}'); + } + if (convertedUint5List.isEmpty) { + throw const FormatException('Empty data'); + } + + int witnessVersion = convertedUint5List[0]; + + if (witnessVersion > 16) { + throw InvalidWitnessVersionException('Invalid witness version $witnessVersion'); + } + + Uint8List witnessProgramUint8List = BytesUtils.convertBits(convertedUint5List.sublist(1), 5, 8, allowPaddingBool: false); + + if (witnessProgramUint8List.length < 2) { + throw InvalidWitnessProgramException('Too short program'); + } + if (witnessProgramUint8List.length > 40) { + throw InvalidWitnessProgramException('Too long program'); + } + if (witnessVersion == 0 && (witnessProgramUint8List.length != 20 && witnessProgramUint8List.length != 32)) { + throw InvalidWitnessProgramException('Invalid version $witnessVersion and invalid program length ${witnessProgramUint8List.length}'); + } + + return SegWit(decodedBech32.hrp, witnessVersion, witnessProgramUint8List); + } +} diff --git a/lib/src/codecs/bech32/seg_wit/seg_wit_encoder.dart b/lib/src/codecs/bech32/seg_wit/seg_wit_encoder.dart new file mode 100644 index 0000000..b26720d --- /dev/null +++ b/lib/src/codecs/bech32/seg_wit/seg_wit_encoder.dart @@ -0,0 +1,64 @@ +// This class was primarily influenced by: +// Copyright 2020 Harm Aarts +// +// 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: +// +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +import 'dart:typed_data'; + +import 'package:codec_utils/codec_utils.dart'; +import 'package:codec_utils/src/codecs/bech32/exceptions/invalid_witness_program_exception.dart'; +import 'package:codec_utils/src/codecs/bech32/exceptions/invalid_witness_version_exception.dart'; +import 'package:codec_utils/src/utils/bytes_utils.dart'; + +/// A utility class for encoding Segregated Witness (SegWit) data +/// into a Bech32-encoded address string. +/// +/// This encoder takes a [SegWit] object and converts it into a valid +/// Bech32 address string following BIP 173 and BIP 350 specifications. +/// +/// ## Validation steps +/// +/// Before encoding, the encoder validates: +/// - The witness version is within the valid range (0–16). +/// - The witness program is neither too short nor too long. +/// - The witness program length is compatible with the specified version. +/// +/// ## Encoding process +/// +/// - Converts the witness program from 8-bit bytes to 5-bit groups (Bech32 data part). +/// - Prefixes the data list with the witness version. +/// - Encodes the final data list together with the human-readable part (HRP) +/// into a Bech32 address string. +class SegWitEncoder { + String encode(SegWit segWit) { + int witnessVersion = segWit.witnessVersion; + Uint8List witnessProgramUint8List = segWit.witnessProgramUint8List; + if (witnessVersion > 16) { + throw InvalidWitnessVersionException('Witness version is invalid $witnessVersion'); + } + if (witnessProgramUint8List.length < 2) { + throw InvalidWitnessProgramException('Witness program is too short ${witnessProgramUint8List.length}'); + } + if (witnessProgramUint8List.length > 40) { + throw InvalidWitnessProgramException('Witness program is too long ${witnessProgramUint8List.length}'); + } + if (witnessVersion == 0 && (witnessProgramUint8List.length != 20 && witnessProgramUint8List.length != 32)) { + throw InvalidWitnessProgramException( + 'Program version $witnessVersion and length of witness program ${witnessProgramUint8List.length} are invalid'); + } + List dataUint5List = BytesUtils.convertBits(witnessProgramUint8List, 8, 5, allowPaddingBool: true); + + List finalUint5List = [witnessVersion] + dataUint5List; + + return Bech32Encoder().encode(Bech32.fromUint5List(segWit.hrp, finalUint5List)); + } +} diff --git a/lib/src/codecs/bech32/segwit_bech32_codec.dart b/lib/src/codecs/bech32/segwit_bech32_codec.dart deleted file mode 100644 index 825eba3..0000000 --- a/lib/src/codecs/bech32/segwit_bech32_codec.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'dart:typed_data'; - -import 'package:bech32/bech32.dart'; - -/// The [SegwitBech32Codec] class is designed for encoding Segregated Witness (SegWit) addresses using the Bech32 encoding scheme, -/// as outlined in BIP-0173 and further refined for SegWit in BIP-0174: -/// https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki -/// https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki -class SegwitBech32Codec { - static String encode(String hrp, int witnessVersion, List witnessProgram) { - // TODO(dominik): Implement custom Segwit encoder - return const SegwitCodec().encode(Segwit(hrp, witnessVersion, witnessProgram)); - } - - static Uint8List decode(String bechAddress) { - // TODO(dominik): Implement custom Segwit decoder - return Uint8List.fromList(const SegwitCodec().decode(bechAddress).program); - } -} diff --git a/lib/src/utils/bytes_utils.dart b/lib/src/utils/bytes_utils.dart new file mode 100644 index 0000000..c72893d --- /dev/null +++ b/lib/src/utils/bytes_utils.dart @@ -0,0 +1,41 @@ +import 'dart:typed_data'; + +class BytesUtils { + /// Converts a list of integers from one bit-grouping to another. + /// + /// This is operation is used to convert between 8-bit bytes and 5-bit groups. + /// + /// - [inputBytesList]: The input list of integers (e.g., 8-bit bytes or 5-bit groups). + /// - [inputBitLength]: The bit length of each element in [dataList] (e.g., 8 for bytes, 5 for Bech32). + /// - [outputBitLength]: The desired bit length of each element in the output list (e.g., 5 for Bech32, 8 for bytes). + /// - [padBool]: If `true`, the result will be padded with zeros to ensure all bits are consumed. + /// If `false`, padding is disallowed, and an error will be thrown if excess + static Uint8List convertBits(List inputBytesList, int inputBitLength, int outputBitLength, {bool allowPaddingBool = true}) { + int acc = 0; + int bits = 0; + List outputBytesList = []; + int outputBitMask = (1 << outputBitLength) - 1; + + for (int inputByte in inputBytesList) { + if (inputByte < 0 || (inputByte >> inputBitLength) != 0) { + throw FormatException('Invalid value $inputBitLength'); + } + acc = (acc << inputBitLength) | inputByte; + bits += inputBitLength; + while (bits >= outputBitLength) { + bits -= outputBitLength; + outputBytesList.add((acc >> bits) & outputBitMask); + } + } + if (allowPaddingBool) { + if (bits > 0) { + outputBytesList.add((acc << (outputBitLength - bits)) & outputBitMask); + } else if (bits >= inputBitLength) { + throw const FormatException('Excess bits require padding, but padding is disallowed.'); + } else if (((acc << (outputBitLength - bits)) & outputBitMask) != 0) { + throw const FormatException('Non-zero padding bits detected.'); + } + } + return Uint8List.fromList(outputBytesList); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index ec1de77..9267116 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: codec_utils description: "Dart package containing utility methods for data encoding and decoding" -version: 0.0.5 +version: 0.0.6 publish_to: none environment: @@ -15,10 +15,6 @@ dependencies: # https://pub.dev/packages/logger logger: 2.4.0 - # Library implementing Bitcoins BIP173 (Bech32 encoding) specification in a Flutter friendly fashion. - # https://pub.dev/packages/bech32 - bech32: 0.2.2 - # Utility functions and classes related to the dart:typed_data library. # https://pub.dev/packages/typed_data typed_data: ^1.3.0 diff --git a/test/unit/codecs/bech32/bech32_codec_test.dart b/test/unit/codecs/bech32/bech32_codec_test.dart deleted file mode 100644 index 663764b..0000000 --- a/test/unit/codecs/bech32/bech32_codec_test.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'dart:convert'; - -import 'package:codec_utils/src/codecs/bech32/bech32_codec.dart'; -import 'package:codec_utils/src/codecs/bech32/bech32_pair.dart'; -import 'package:test/test.dart'; - -void main() { - group('Tests of Bech32Codec.encode()', () { - test('Should [return String] encoded by Bech32', () { - // Arrange - Bech32Pair actualBech32Pair = Bech32Pair(hrp: 'crypto', data: base64Decode('KxmiVli7oFEs8N5rjnzLtw7eym0=')); - - // Act - String actualEncodedData = Bech32Codec.encode(actualBech32Pair); - - // Assert - String expectedEncodedData = 'crypto19vv6y4jchws9zt8sme4culxtku8dajndgyhdm2'; - - expect(actualEncodedData, expectedEncodedData); - }); - }); - - group('Tests of Bech32Codec.decode()', () { - test('Should [return Bech32Pair] decoded by Bech32', () { - // Arrange - String actualDataToDecode = 'crypto19vv6y4jchws9zt8sme4culxtku8dajndgyhdm2'; - - // Act - Bech32Pair actualBech32Pair = Bech32Codec.decode(actualDataToDecode); - - // Assert - Bech32Pair expectedBech32Pair = Bech32Pair(hrp: 'crypto', data: base64Decode('KxmiVli7oFEs8N5rjnzLtw7eym0=')); - - expect(actualBech32Pair, expectedBech32Pair); - }); - }); -} diff --git a/test/unit/codecs/bech32/bech32_decoder_test.dart b/test/unit/codecs/bech32/bech32_decoder_test.dart new file mode 100644 index 0000000..d2dad3f --- /dev/null +++ b/test/unit/codecs/bech32/bech32_decoder_test.dart @@ -0,0 +1,93 @@ +import 'dart:typed_data'; + +import 'package:codec_utils/src/codecs/bech32/bech32.dart'; +import 'package:codec_utils/src/codecs/bech32/bech32_decoder.dart'; +import 'package:codec_utils/src/codecs/bech32/exceptions/invalid_bech32_exception.dart'; +import 'package:codec_utils/src/codecs/bech32/exceptions/invalid_checksum_exception.dart'; +import 'package:codec_utils/src/codecs/bech32/exceptions/invalid_hrp_exception.dart'; +import 'package:test/test.dart'; + +void main() { + group('Tests of Bech32Decoder.decode()', () { + test('Should [return decoded data] for valid Bech32 address', () { + // Arrange + String actualInput = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'; + + // Act + Bech32 actualBech32 = Bech32Decoder().decode(actualInput); + + // Assert + String expectedHrp = 'bc'; + Uint8List expectedDataUint8List = + Uint8List.fromList([3, 168, 243, 183, 64, 204, 140, 182, 162, 164, 160, 226, 46, 141, 157, 25, 31, 138, 25, 222, 176]); + + Bech32 expectedBech32 = Bech32(expectedHrp, expectedDataUint8List); + + expect(actualBech32, expectedBech32); + }); + + test('Should [throw InvalidBech32Exception] if input exceeds maximum length', () { + // Arrange + String actualInput = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + + // Assert + expect(() => Bech32Decoder().decode(actualInput), throwsA(isA())); + }); + + test('Should [throw InvalidBech32Exception] if input contains uppercase characters', () { + // Arrange + String actualInput = 'Bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kg3g4ty'; + + // Assert + expect(() => Bech32Decoder().decode(actualInput), throwsA(isA())); + }); + + test('Should [throw InvalidBech32Exception] if separator "1" is missing', () { + // Arrange + String actualInput = 'bcqw508d6qejxtdg4y5r3zarvary0c5xw7kg3g4ty'; + + // Assert + expect(() => Bech32Decoder().decode(actualInput), throwsA(isA())); + }); + + test('Should [throw InvalidChecksumException] if checksum is invalid due to invalid trailing characters', () { + // Arrange + String actualInput = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080'; + + // Assert + expect(() => Bech32Decoder().decode(actualInput), throwsA(isA())); + }); + + test('Should [throw InvalidHrpException] if HRP is empty', () { + // Arrange + String actualInput = '1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'; + + // Assert + expect(() => Bech32Decoder().decode(actualInput), throwsA(isA())); + }); + + test('Should [throw InvalidHrpException] if HRP contains invalid characters', () { + // Arrange + String actualInput = '@bc 1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'; + + // Assert + expect(() => Bech32Decoder().decode(actualInput), throwsA(isA())); + }); + + test('Should [throw InvalidChecksumException] if checksum contains invalid character (e.g. "!")', () { + // Arrange + String actualInput = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kg3g4t!'; + + // Assert + expect(() => Bech32Decoder().decode(actualInput), throwsA(isA())); + }); + + test('Should [throw InvalidChecksumException] if checksum is incorrect (valid format, invalid sum)', () { + // Arrange + String actualInput = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kg3g4tx'; + + // Assert + expect(() => Bech32Decoder().decode(actualInput), throwsA(isA())); + }); + }); +} diff --git a/test/unit/codecs/bech32/bech32_encoder_test.dart b/test/unit/codecs/bech32/bech32_encoder_test.dart new file mode 100644 index 0000000..de57f6d --- /dev/null +++ b/test/unit/codecs/bech32/bech32_encoder_test.dart @@ -0,0 +1,50 @@ +import 'dart:typed_data'; + +import 'package:codec_utils/codec_utils.dart'; +import 'package:codec_utils/src/codecs/bech32/exceptions/invalid_bech32_exception.dart'; +import 'package:codec_utils/src/codecs/bech32/exceptions/invalid_hrp_exception.dart'; +import 'package:test/test.dart'; + +void main() { + group('Tests of Bech32Encoder.encode()', () { + test('Should [return valid Bech32 address] from provided bytes', () { + // Arrange + Uint8List actualUint8List = + Uint8List.fromList([3, 168, 243, 183, 64, 204, 140, 182, 162, 164, 160, 226, 46, 141, 157, 25, 31, 138, 25, 222, 176]); + Bech32 actualBechInput = Bech32('bc', actualUint8List); + + // Act + String actualEncoded = Bech32Encoder().encode(Bech32(actualBechInput.hrp, actualUint8List)); + + // Assert + String expectedEncoded = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'; + + expect(actualEncoded, expectedEncoded); + }); + + test('Should [throw InvalidLengthException] if encoded Bech32 string exceeds 90 characters', () { + // Arrange + Uint8List actualUint8List = Uint8List.fromList([ + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 + ]); + Bech32 actualInput = Bech32('bc', actualUint8List); + + // Assert + expect(() => Bech32Encoder().encode(actualInput), throwsA(isA())); + }); + + test('Should [throw InvalidHRPException] if HRP is empty', () { + // Arrange + Bech32 actualInput = Bech32('', Uint8List.fromList([0, 1, 2])); + + // Assert + expect( + () => Bech32Encoder().encode(actualInput), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/unit/codecs/bech32/bech32_utils_test.dart b/test/unit/codecs/bech32/bech32_utils_test.dart new file mode 100644 index 0000000..82ff243 --- /dev/null +++ b/test/unit/codecs/bech32/bech32_utils_test.dart @@ -0,0 +1,35 @@ +import 'package:codec_utils/src/codecs/bech32/bech32_utils.dart'; +import 'package:test/expect.dart'; +import 'package:test/scaffolding.dart'; + +void main() { + group('Tests of Bech32Utils.calculatePolymodChecksum()', () { + test('Should [return polymod checksum] for given data', () { + // Arrange + List actualInputUint5List = [3, 3, 0, 2, 3]; + + // Act + int actualResult = Bech32Utils.calculatePolymodChecksum(actualInputUint5List); + + // Assert + int expectedResult = 36798531; + + expect(actualResult, expectedResult); + }); + }); + + group('Tests of Bech32Utils.expandHrp()', () { + test('Should [return expended HRP] for given data', () { + // Arrange + String actualHrp = 'bc'; + + // Act + List actualUint5List = Bech32Utils.expandHrp(actualHrp); + + // Assert + List expectedUint5List = [3, 3, 0, 2, 3]; + + expect(actualUint5List, expectedUint5List); + }); + }); +} diff --git a/test/unit/codecs/bech32/seg_wit/seg_wit_decoder_test.dart b/test/unit/codecs/bech32/seg_wit/seg_wit_decoder_test.dart new file mode 100644 index 0000000..69c10f2 --- /dev/null +++ b/test/unit/codecs/bech32/seg_wit/seg_wit_decoder_test.dart @@ -0,0 +1,79 @@ +import 'dart:typed_data'; + +import 'package:codec_utils/codec_utils.dart'; +import 'package:codec_utils/src/codecs/bech32/exceptions/invalid_bech32_exception.dart'; +import 'package:codec_utils/src/codecs/bech32/exceptions/invalid_hrp_exception.dart'; +import 'package:codec_utils/src/codecs/bech32/exceptions/invalid_witness_program_exception.dart'; +import 'package:codec_utils/src/codecs/bech32/exceptions/invalid_witness_version_exception.dart'; +import 'package:test/test.dart'; + +void main() { + group('Test of SegWitDecoder.decode()', () { + test('Should [return decoded data] based on given data', () { + // Arrange + String actualData = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'; + + // Act + SegWit actualDecodedData = SegWitDecoder().decode(actualData); + + // Assert + String expectedHrp = 'bc'; + int expectedWitnessVersion = 0; + Uint8List expectedUint8List = Uint8List.fromList( + [117, 30, 118, 232, 25, 145, 150, 212, 84, 148, 28, 69, 209, 179, 163, 35, 241, 67, 59, 214], + ); + + SegWit expectedDecodedData = SegWit(expectedHrp, expectedWitnessVersion, expectedUint8List); + + expect(actualDecodedData, expectedDecodedData); + }); + + test('Should [throw InvalidBech32Exception] if input string is empty', () { + // Arrange + String actualData = ''; + + // Assert + expect(() => SegWitDecoder().decode(actualData), throwsA(isA())); + }); + + test('Should [throw InvalidHRPException] if HRP contains invalid characters', () { + // Arrange + String actualData = 'zz1pzry9x8gf2tvdw0s3jn5xsa9te'; + + // Assert + expect(() => SegWitDecoder().decode(actualData), throwsA(isA())); + }); + + test('Should [throw FormatException] if witness program is empty after decoding', () { + // Arrange + String actualData = 'bc1gmk9yu'; + + // Assert + expect(() => SegWitDecoder().decode(actualData), throwsA(isA())); + }); + + test('Should [throw InvalidWitnessVersionException] if witness version is out of allowed range (0–16)', () { + // Arrange + String actualData = 'bc13qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5k252qw'; + + // Assert + expect(() => SegWitDecoder().decode(actualData), throwsA(isA())); + }); + + test('Should [throw InvalidProgramException] if witness program length is too short', () { + // Arrange + String actualData = 'bc1qqypf4jh4k'; + + // Assert + expect(() => SegWitDecoder().decode(actualData), throwsA(isA())); + }); + + test('Should [throw InvalidProgramException] if witness program length is too long', () { + // Arrange + String actualData = 'bc1qqypqxpq9qcrsszg2pvxq6rs0zqg3yyc5z5tpwxqergd3c8g7ruszzg3rysjjvfeg9y4zktpd9chnqvfjqaly2h'; + + // Assert + expect(() => SegWitDecoder().decode(actualData), throwsA(isA())); + }); + }); +} diff --git a/test/unit/codecs/bech32/seg_wit/seg_wit_encoder_test.dart b/test/unit/codecs/bech32/seg_wit/seg_wit_encoder_test.dart new file mode 100644 index 0000000..bd571be --- /dev/null +++ b/test/unit/codecs/bech32/seg_wit/seg_wit_encoder_test.dart @@ -0,0 +1,71 @@ +import 'dart:typed_data'; + +import 'package:codec_utils/src/codecs/bech32/exceptions/invalid_witness_program_exception.dart'; +import 'package:codec_utils/src/codecs/bech32/exceptions/invalid_witness_version_exception.dart'; +import 'package:codec_utils/src/codecs/bech32/seg_wit/seg_wit.dart'; +import 'package:codec_utils/src/codecs/bech32/seg_wit/seg_wit_encoder.dart'; +import 'package:test/expect.dart'; +import 'package:test/scaffolding.dart'; + +void main() { + group('Tests of SegWitEncoder.encode()', () { + test('Should [return converted data] based on given data', () { + // Arrange + Uint8List actualUint8List = Uint8List.fromList([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]); + SegWit actualSegWit = SegWit('bc', 0, actualUint8List); + + // Act + String actualEncodedData = SegWitEncoder().encode(actualSegWit); + + // Assert + String expectedEncodedData = 'bc1qqqqsyqcyq5rqwzqfpg9scrgwpugpzysn4v0345'; + + expect(actualEncodedData, expectedEncodedData); + }); + + test('Should [throw InvalidWitnessVersionException] if witness version is greater than 16', () { + // Arrange + Uint8List actualUint8List = Uint8List.fromList([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]); + SegWit actualSegWit = SegWit('bc', 17, actualUint8List); + + // Assert + expect(() => SegWitEncoder().encode(actualSegWit), throwsA(isA())); + }); + + test('Should [throw InvalidProgramException] if witness program length is less than 2', () { + // Arrange + Uint8List actualUint8List = Uint8List.fromList([0]); + SegWit actualSegWit = SegWit('bc', 0, actualUint8List); + + // Assert + expect(() => SegWitEncoder().encode(actualSegWit), throwsA(isA())); + }); + + test('Should [throw InvalidProgramException] if witness program length exceeds 40 bytes', () { + // Arrange + Uint8List actualUint8List = Uint8List.fromList([ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, // + 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, + 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 + ]); + + SegWit actualSegWit = SegWit('bc', 0, actualUint8List); + + // Assert + expect(() => SegWitEncoder().encode(actualSegWit), throwsA(isA())); + }); + + test('Should [throw InvalidProgramException] if witness program length is 31 bytes (invalid for version 0)', () { + // Arrange + Uint8List actualUint8List = Uint8List.fromList([ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, // + 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29 + ]); + + SegWit actualSegWit = SegWit('bc', 0, actualUint8List); + + // Assert + expect(() => SegWitEncoder().encode(actualSegWit), throwsA(isA())); + }); + }); +} diff --git a/test/unit/codecs/bech32/segwit_bech32_codec_test.dart b/test/unit/codecs/bech32/segwit_bech32_codec_test.dart deleted file mode 100644 index d898b2f..0000000 --- a/test/unit/codecs/bech32/segwit_bech32_codec_test.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:codec_utils/src/codecs/bech32/segwit_bech32_codec.dart'; -import 'package:test/test.dart'; - -void main() { - group('Tests of SegwitBech32Codec.encode()', () { - test('Should [return String] encoded by Bech32 (Segwit version)', () { - // Arrange - Uint8List actualDataToEncode = base64Decode('KxmiVli7oFEs8N5rjnzLtw7eym0='); - - // Act - String actualEncodedData = SegwitBech32Codec.encode('bc', 0, actualDataToEncode); - - // Assert - String expectedEncodedData = 'bc1q9vv6y4jchws9zt8sme4culxtku8dajnd5jq660'; - - expect(actualEncodedData, expectedEncodedData); - }); - }); - - group('Tests of SegwitBech32Codec.decode()', () { - test('Should [return String] encoded by Bech32 (Segwit version)', () { - // Arrange - String actualDataToDecode = 'bc1q9vv6y4jchws9zt8sme4culxtku8dajnd5jq660'; - - // Act - Uint8List actualDecodedData = SegwitBech32Codec.decode(actualDataToDecode); - - // Assert - Uint8List expectedDecodedData = base64Decode('KxmiVli7oFEs8N5rjnzLtw7eym0='); - - expect(actualDecodedData, expectedDecodedData); - }); - }); -} diff --git a/test/unit/utils/bytes_utils_test.dart b/test/unit/utils/bytes_utils_test.dart new file mode 100644 index 0000000..0e906f0 --- /dev/null +++ b/test/unit/utils/bytes_utils_test.dart @@ -0,0 +1,62 @@ +import 'package:codec_utils/src/utils/bytes_utils.dart'; +import 'package:test/expect.dart'; +import 'package:test/scaffolding.dart'; + +void main() { + group('Tests of BytesUtils.convertBits()', () { + test('Should [return converted bits] from 8 to 5 with padding', () { + // Arrange + List actualInputList = [255]; + + // Act + List actualOutputList = BytesUtils.convertBits(actualInputList, 8, 5, allowPaddingBool: true); + + // Assert + List expectedOutputList = [31, 28]; + + expect(actualOutputList, expectedOutputList); + }); + + test('Should [return converted bits] from 8 to 5 without padding', () { + // Arrange + List actualInputList = [30]; + + // Act + List actualOutputList = BytesUtils.convertBits(actualInputList, 8, 5, allowPaddingBool: false); + + // Assert + List expectedOutputList = [3]; + + expect(actualOutputList, expectedOutputList); + }); + + test('Should [return converted bits] from 5 to 8 with padding', () { + // Arrange + List actualInputList = [3, 30]; + + // Act + List actualOutputList = BytesUtils.convertBits(actualInputList, 5, 8, allowPaddingBool: true); + + // Assert + List expectedOutputList = [31, 128]; + + expect(actualOutputList, expectedOutputList); + }); + + test('Should [throw FormatException] for invalid input', () { + // Arrange + List actualInputList = [-1]; + + // Assert + expect(() => BytesUtils.convertBits(actualInputList, 8, 5, allowPaddingBool: true), throwsA(isA())); + }); + + test('Should [throw FormatException] for invalid input', () { + // Arrange + List actualInputList = [256]; + + // Assert + expect(() => BytesUtils.convertBits(actualInputList, 8, 5, allowPaddingBool: true), throwsA(isA())); + }); + }); +}