From 8247d60ad446191874bb2574636bc0067b235fad Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 16 Jul 2026 21:58:05 -0800 Subject: [PATCH 1/6] auto format --- coinlib/lib/src/address.dart | 127 +++++++++++++++-------------------- 1 file changed, 56 insertions(+), 71 deletions(-) diff --git a/coinlib/lib/src/address.dart b/coinlib/lib/src/address.dart index ab9d7e9..9e14b6b 100644 --- a/coinlib/lib/src/address.dart +++ b/coinlib/lib/src/address.dart @@ -1,11 +1,12 @@ import 'dart:typed_data'; + import 'package:coinlib/src/common/bytes.dart'; import 'package:coinlib/src/common/hex.dart'; import 'package:coinlib/src/crypto/ec_public_key.dart'; import 'package:coinlib/src/crypto/hash.dart'; -import 'package:coinlib/src/network.dart'; import 'package:coinlib/src/encode/base58.dart'; import 'package:coinlib/src/encode/bech32.dart'; +import 'package:coinlib/src/network.dart'; import 'package:coinlib/src/scripts/program.dart'; import 'package:coinlib/src/scripts/programs/p2pkh.dart'; import 'package:coinlib/src/scripts/programs/p2sh.dart'; @@ -17,12 +18,12 @@ import 'package:coinlib/src/scripts/script.dart'; import 'package:coinlib/src/taproot/taproot.dart'; class InvalidAddress implements Exception {} + class InvalidAddressNetwork implements Exception {} /// Base class for all addresses. Encoded addresses for sub-classes are provided /// via the [toString()] method. abstract class Address { - /// Decodes an address string ([encoded]) and returns the sub-class object for /// the type of address. Throws [InvalidAddress], [InvalidAddressNetwork], /// [InvalidBech32Checksum] or [InvalidBase58Checksum] if there is an error @@ -31,7 +32,7 @@ abstract class Address { // Try base58 try { return Base58Address.fromString(encoded, network); - } on Exception catch(e) { + } on Exception catch (e) { // If not base58, then try bech32 try { return Bech32Address.fromString(encoded, network); @@ -49,27 +50,26 @@ abstract class Address { } Program get program; - } /// Base class for all addresses that use base58: [P2PKHAddress] and /// [P2SHAddress]. abstract class Base58Address implements Address { - /// The 160bit public key or redeemScript hash for the base58 address final Uint8List _hash; + /// The network and address type version of the address final int version; String? _encodedCache; Base58Address._(Uint8List hash, this.version) : _hash = hash { if (version < 0 || version > 255) { - throw ArgumentError("base58 version must be within 0-255", "this.version"); + throw ArgumentError( + "base58 version must be within 0-255", "this.version"); } } factory Base58Address.fromString(String encoded, Network network) { - final data = base58Decode(encoded); if (data.length != 21) throw InvalidAddress(); @@ -87,64 +87,57 @@ abstract class Base58Address implements Address { addr._encodedCache = encoded; return addr; - } @override toString() => _encodedCache ??= base58Encode( - Uint8List.fromList([version, ..._hash]), - ); + Uint8List.fromList([version, ..._hash]), + ); Uint8List get hash => Uint8List.fromList(_hash); - } class P2PKHAddress extends Base58Address { - /// Takes a [hash] directly for a P2PKH address - P2PKHAddress.fromHash(Uint8List hash, { required int version }) - : super._(copyCheckBytes(hash, 20), version); + P2PKHAddress.fromHash(Uint8List hash, {required int version}) + : super._(copyCheckBytes(hash, 20), version); /// Constructs a P2PKH address from a given [pubkey]. - P2PKHAddress.fromPublicKey(ECPublicKey pubkey, { required int version }) - : this.fromHash(hash160(pubkey.data), version: version); + P2PKHAddress.fromPublicKey(ECPublicKey pubkey, {required int version}) + : this.fromHash(hash160(pubkey.data), version: version); @override P2PKH get program => P2PKH.fromHash(hash); - } class P2SHAddress extends Base58Address { - /// Constructs a P2SH address from the redeemScript [hash]. - P2SHAddress.fromHash(Uint8List hash, { required int version }) - : super._(copyCheckBytes(hash, 20), version); + P2SHAddress.fromHash(Uint8List hash, {required int version}) + : super._(copyCheckBytes(hash, 20), version); /// Constructs a P2SH address for a redeemScript - P2SHAddress.fromRedeemScript(Script script, { required int version }) - : super._(hash160(script.compiled), version); + P2SHAddress.fromRedeemScript(Script script, {required int version}) + : super._(hash160(script.compiled), version); @override P2SH get program => P2SH.fromHash(hash); - } /// Base class for addresses that use bech32: [P2WPKHAddress] and /// [P2WSHAddress]. Unknown witness programs are encoded via /// [UnknownWitnessAddress]. abstract class Bech32Address implements Address { - static const maxWitnessProgramLength = 40; /// The human readable part of the address used to specify the network final String hrp; + /// The program version of the address final int version; final Uint8List _data; String? _encodedCache; Bech32Address._(this.version, this._data, this.hrp) { - if (version < 0 || version > 16) { throw ArgumentError("bech32 version must be 0-16", "this.version"); } @@ -159,21 +152,20 @@ abstract class Bech32Address implements Address { } final encodedLength - // Encoded program length - = (_data.length*8+4)/5 - // Seperator and version - + 2 - + hrp.length - + Bech32.checksumLength; + // Encoded program length + = (_data.length * 8 + 4) / 5 + // Seperator and version + + + 2 + + hrp.length + + Bech32.checksumLength; if (encodedLength > Bech32.maxLength) { throw ArgumentError("Bech32Address arguments exceed allowable size"); } - } factory Bech32Address.fromString(String encoded, Network network) { - final bech32 = Bech32.decode(encoded); if (bech32.words.isEmpty) throw InvalidAddress(); @@ -182,10 +174,8 @@ abstract class Bech32Address implements Address { final version = bech32.words[0]; // Must use bech32m starting with version 1 - if ( - (version == 0 && bech32.type != Bech32Type.bech32) - || (version != 0 && bech32.type != Bech32Type.bech32m) - ) { + if ((version == 0 && bech32.type != Bech32Type.bech32) || + (version != 0 && bech32.type != Bech32Type.bech32m)) { throw InvalidAddress(); } @@ -224,86 +214,81 @@ abstract class Bech32Address implements Address { addr._encodedCache = encoded; return addr; - } @override toString() => _encodedCache ??= Bech32( - hrp: hrp, - words: List.from([ - version, ...convertBits(_data, 8, 5, true)!, - ]), - type: version == 0 ? Bech32Type.bech32 : Bech32Type.bech32m, - ).encode(); + hrp: hrp, + words: List.from([ + version, + ...convertBits(_data, 8, 5, true)!, + ]), + type: version == 0 ? Bech32Type.bech32 : Bech32Type.bech32m, + ).encode(); /// The "witness program" data encoded in the address Uint8List get data => Uint8List.fromList(_data); - } class P2WPKHAddress extends Bech32Address { - /// Constructs a P2WPKH address directly from the [hash] - P2WPKHAddress.fromHash(Uint8List hash, { required String hrp }) - : super._(0, copyCheckBytes(hash, 20), hrp); + P2WPKHAddress.fromHash(Uint8List hash, {required String hrp}) + : super._(0, copyCheckBytes(hash, 20), hrp); /// Constructs a P2WPKH address from a [pubkey] - P2WPKHAddress.fromPublicKey(ECPublicKey pubkey, { required String hrp }) - : this.fromHash(hash160(pubkey.data), hrp: hrp); + P2WPKHAddress.fromPublicKey(ECPublicKey pubkey, {required String hrp}) + : this.fromHash(hash160(pubkey.data), hrp: hrp); @override P2WPKH get program => P2WPKH.fromHash(_data); - } class P2WSHAddress extends Bech32Address { - /// Constructs a P2WSH address from the script [hash] - P2WSHAddress.fromHash(Uint8List hash, { required String hrp }) - : super._(0, copyCheckBytes(hash, 32), hrp); + P2WSHAddress.fromHash(Uint8List hash, {required String hrp}) + : super._(0, copyCheckBytes(hash, 32), hrp); /// Constructs a P2WSH address for a witnessScript - P2WSHAddress.fromWitnessScript(Script script, { required String hrp }) - : super._(0, sha256Hash(script.compiled), hrp); + P2WSHAddress.fromWitnessScript(Script script, {required String hrp}) + : super._(0, sha256Hash(script.compiled), hrp); @override P2WSH get program => P2WSH.fromHash(_data); - } class P2TRAddress extends Bech32Address { + P2TRAddress.fromTweakedKeyX(Uint8List tweakedKeyX, {required String hrp}) + : super._(1, copyCheckBytes(tweakedKeyX, 32), hrp); - P2TRAddress.fromTweakedKeyX(Uint8List tweakedKeyX, { required String hrp }) - : super._(1, copyCheckBytes(tweakedKeyX, 32), hrp); + P2TRAddress.fromTweakedKey(ECPublicKey tweakedKey, {required String hrp}) + : super._(1, tweakedKey.x, hrp); - P2TRAddress.fromTweakedKey(ECPublicKey tweakedKey, { required String hrp }) - : super._(1, tweakedKey.x, hrp); - - P2TRAddress.fromTaproot(Taproot taproot, { required String hrp }) - : super._(1, taproot.tweakedKey.x, hrp); + P2TRAddress.fromTaproot(Taproot taproot, {required String hrp}) + : super._(1, taproot.tweakedKey.x, hrp); @override P2TR get program => P2TR.fromTweakedKeyX(_data); - } /// This address type is for all bech32 addresses that do not match known /// witness versions. class UnknownWitnessAddress extends Bech32Address { - /// Constructs a bech32 witness address from the "witness program" [data], /// witness [version] and [hrp] UnknownWitnessAddress( - Uint8List data, { required int version, required String hrp, } - ) : super._(version, data, hrp); + Uint8List data, { + required int version, + required String hrp, + }) : super._(version, data, hrp); /// Constructs a bech32 witness address with the "witness program" [data] /// provided as a [hex] string. UnknownWitnessAddress.fromHex( - String hex, { required int version, required String hrp, } - ) : super._(version, hexToBytes(hex), hrp); + String hex, { + required int version, + required String hrp, + }) : super._(version, hexToBytes(hex), hrp); @override P2Witness get program => P2Witness.fromData(version, _data); - } From 7800486f3873de011696c9d8fdb21f06ce2f3224 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 16 Jul 2026 21:58:05 -0800 Subject: [PATCH 2/6] auto format --- coinlib/lib/src/encode/bech32.dart | 56 ++++++++++++++---------------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/coinlib/lib/src/encode/bech32.dart b/coinlib/lib/src/encode/bech32.dart index a525277..de1a115 100644 --- a/coinlib/lib/src/encode/bech32.dart +++ b/coinlib/lib/src/encode/bech32.dart @@ -1,8 +1,7 @@ final alphabet = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; /// Returns true if a bech32 hrp uses valid characters. -bool hrpValid(String hrp) - => hrp.codeUnits.every((c) => c > 32 && c < 127); +bool hrpValid(String hrp) => hrp.codeUnits.every((c) => c > 32 && c < 127); void _throwOnInvalidHrp(String hrp) { if (!hrpValid(hrp)) { @@ -10,14 +9,14 @@ void _throwOnInvalidHrp(String hrp) { } } -List _charsToWords(String encoded) - => encoded.codeUnits.map((c) => alphabet.codeUnits.indexOf(c)).toList(); +List _charsToWords(String encoded) => + encoded.codeUnits.map((c) => alphabet.codeUnits.indexOf(c)).toList(); List _hrpExpand(String hrp) => [ - ...hrp.codeUnits.map((c) => c >> 5), - 0, - ...hrp.codeUnits.map((c) => c & 31), -]; + ...hrp.codeUnits.map((c) => c >> 5), + 0, + ...hrp.codeUnits.map((c) => c & 31), + ]; int _polymodStep(int pre) { final b = pre >> 25; @@ -29,11 +28,11 @@ int _polymodStep(int pre) { (-((b >> 4) & 1) & 0x2a1462b3)); } -int _polymod(String hrp, List words) - => [1, ..._hrpExpand(hrp), ...words].reduce((a, b) => _polymodStep(a) ^ b); +int _polymod(String hrp, List words) => + [1, ..._hrpExpand(hrp), ...words].reduce((a, b) => _polymodStep(a) ^ b); List _checksum(String hrp, List words, int checkConst) { - final polymod = _polymod(hrp, [...words,0,0,0,0,0,0]) ^ checkConst; + final polymod = _polymod(hrp, [...words, 0, 0, 0, 0, 0, 0]) ^ checkConst; return List.generate(6, (i) => (polymod >> 5 * (5 - i)) & 31); } @@ -41,7 +40,6 @@ List _checksum(String hrp, List words, int checkConst) { /// ([pad]). Returns the new data or null if conversion was not possible. /// Used to convert to and from the 5-bit words for bech32. List? convertBits(List data, int from, int to, bool pad) { - var acc = 0; var bits = 0; List result = []; @@ -66,13 +64,13 @@ List? convertBits(List data, int from, int to, bool pad) { } return result; - } class InvalidBech32 implements Exception { final String message; InvalidBech32([this.message = ""]); } + class InvalidBech32Checksum implements Exception {} enum Bech32Type { bech32, bech32m } @@ -80,7 +78,6 @@ enum Bech32Type { bech32, bech32m } /// Encapsules 5-bit words that can be bech32 encoded and decoded. The 5-bit /// words may need to be further converted into the required data. class Bech32 { - static const maxLength = 90; static const checksumLength = 6; static const bech32CheckConst = 1; @@ -91,15 +88,19 @@ class Bech32 { final Bech32Type type; Bech32._skipValidation({ - required this.hrp, required List words, required this.type, - }): words = List.unmodifiable(words); + required this.hrp, + required List words, + required this.type, + }) : words = List.unmodifiable(words); /// Creates an encodable object with the human-readable-part ([hrp]), [words] /// for the given bech32 [type] (bech32 or bech32m). Bech32({ - required String hrp, required List words, required this.type, - }) : hrp = hrp.toLowerCase(), words = List.unmodifiable(words) { - + required String hrp, + required List words, + required this.type, + }) : hrp = hrp.toLowerCase(), + words = List.unmodifiable(words) { if (hrp.isEmpty) throw InvalidBech32("Missing HRP"); _throwOnInvalidHrp(hrp); @@ -110,14 +111,12 @@ class Bech32 { if (hrp.length + 1 + words.length + checksumLength > maxLength) { throw InvalidBech32("Bech32 too long"); } - } /// Decodes a bech32 string into the hrp, 5-bit words and type. May throw an /// [InvalidBech32]. It will throw [InvalidBech32Checksum] if the bech32 is /// valid but doesn't have a valid checksum for either bech32 or bech32m. factory Bech32.decode(String encoded) { - if (encoded.length > maxLength) { throw InvalidBech32("Bech32 too long"); } @@ -153,28 +152,27 @@ class Bech32 { throw InvalidBech32Checksum(); } - final bech32 = Bech32._skipValidation(hrp: hrp, words: dataWords, type: type); + final bech32 = + Bech32._skipValidation(hrp: hrp, words: dataWords, type: type); bech32._encodedCache = encoded; return bech32; - } String? _encodedCache; + /// Encodes a bech32 string String encode() { - if (_encodedCache != null) return _encodedCache!; final wordsWithChecksum = [ - ...words, ..._checksum( - hrp, words, + ...words, + ..._checksum( + hrp, + words, type == Bech32Type.bech32 ? bech32CheckConst : bech32mCheckConst, ), ]; final encodedWords = wordsWithChecksum.map((w) => alphabet[w]).join(); return _encodedCache = "${hrp}1$encodedWords"; - } - } - From c5ad0573599da3157d479549990ec27a7db586b3 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 16 Jul 2026 21:58:05 -0800 Subject: [PATCH 3/6] WIP: quick and dirty ltc mweb support --- coinlib/lib/src/address.dart | 111 ++++++++++++++++----- coinlib/lib/src/encode/bech32.dart | 11 +- coinlib/lib/src/scripts/programs/mweb.dart | 32 ++++++ 3 files changed, 126 insertions(+), 28 deletions(-) create mode 100644 coinlib/lib/src/scripts/programs/mweb.dart diff --git a/coinlib/lib/src/address.dart b/coinlib/lib/src/address.dart index 9e14b6b..3356aa2 100644 --- a/coinlib/lib/src/address.dart +++ b/coinlib/lib/src/address.dart @@ -8,6 +8,7 @@ import 'package:coinlib/src/encode/base58.dart'; import 'package:coinlib/src/encode/bech32.dart'; import 'package:coinlib/src/network.dart'; import 'package:coinlib/src/scripts/program.dart'; +import 'package:coinlib/src/scripts/programs/mweb.dart'; import 'package:coinlib/src/scripts/programs/p2pkh.dart'; import 'package:coinlib/src/scripts/programs/p2sh.dart'; import 'package:coinlib/src/scripts/programs/p2tr.dart'; @@ -65,7 +66,9 @@ abstract class Base58Address implements Address { Base58Address._(Uint8List hash, this.version) : _hash = hash { if (version < 0 || version > 255) { throw ArgumentError( - "base58 version must be within 0-255", "this.version"); + "base58 version must be within 0-255", + "this.version", + ); } } @@ -127,8 +130,6 @@ class P2SHAddress extends Base58Address { /// [P2WSHAddress]. Unknown witness programs are encoded via /// [UnknownWitnessAddress]. abstract class Bech32Address implements Address { - static const maxWitnessProgramLength = 40; - /// The human readable part of the address used to specify the network final String hrp; @@ -137,16 +138,16 @@ abstract class Bech32Address implements Address { final Uint8List _data; String? _encodedCache; - Bech32Address._(this.version, this._data, this.hrp) { + Bech32Address._( + this.version, + this._data, + this.hrp, { + bool ignoreMaxLengthCheckForMweb = false, + }) { if (version < 0 || version > 16) { throw ArgumentError("bech32 version must be 0-16", "this.version"); } - if (_data.length < 2 || _data.length > maxWitnessProgramLength) { - throw ArgumentError( - "witness programs must be 2-$maxWitnessProgramLength in length", - "this.program", - ); - } + if (!hrpValid(hrp)) { throw ArgumentError("Invalid hrp: $hrp", "this.hrp"); } @@ -160,13 +161,19 @@ abstract class Bech32Address implements Address { hrp.length + Bech32.checksumLength; - if (encodedLength > Bech32.maxLength) { - throw ArgumentError("Bech32Address arguments exceed allowable size"); + if (!ignoreMaxLengthCheckForMweb && encodedLength > Bech32.maxLength) { + throw ArgumentError( + "Bech32Address arguments exceed allowable size: $encodedLength", + ); } } + // Refactored factory: hrpOverride parameter removed factory Bech32Address.fromString(String encoded, Network network) { - final bech32 = Bech32.decode(encoded); + final bech32 = Bech32.decode( + encoded, + ignoreMaxLengthCheckForMweb: network.bech32Hrp == "ltcmweb", + ); if (bech32.words.isEmpty) throw InvalidAddress(); if (bech32.hrp != network.bech32Hrp) throw InvalidAddressNetwork(); @@ -186,25 +193,34 @@ abstract class Bech32Address implements Address { late Bech32Address addr; - if (version == 0) { + // TODO account for testnet (don't hardcode this) + if (network.bech32Hrp == "ltcmweb" && version == 0) { + if (bytes.length != MwebAddress._expectedProgramLength) { + throw Exception( + "Invalid MWEB witness program length: ${bytes.length}, expected ${MwebAddress._expectedProgramLength}", + ); + } + addr = MwebAddress.fromHash(hash: bytes, hrp: bech32.hrp); + } else if (version == 0) { // Version 0 signals P2WPKH or P2WSH - if (bytes.length == 20) { + if (bytes.length == P2WPKHAddress._expectedProgramLength) { addr = P2WPKHAddress.fromHash(bytes, hrp: bech32.hrp); - } else if (bytes.length == 32) { + } else if (bytes.length == P2WSHAddress._expectedProgramLength) { addr = P2WSHAddress.fromHash(bytes, hrp: bech32.hrp); } else { - throw InvalidAddress(); + throw InvalidAddress(); // Unexpected length for P2WPKH/P2WSH v0 } } else if (version == 1) { - // Version 1 is Taproot - if (bytes.length == 32) { + // Version 1 is Taproot (P2TR) + if (bytes.length == P2TRAddress._expectedProgramLength) { addr = P2TRAddress.fromTweakedKeyX(bytes, hrp: bech32.hrp); } else { - throw InvalidAddress(); + throw InvalidAddress(); // Unexpected length for P2TR v1 } } else if (version <= 16) { - // Treat other versions as unknown. - if (bytes.length < 2 || bytes.length > maxWitnessProgramLength) { + // Treat other versions as unknown, but still require valid length range (e.g., 2-40) + if (bytes.length < 2 || bytes.length > 40) { + // Using 40 as a general max for unknown throw InvalidAddress(); } addr = UnknownWitnessAddress(bytes, hrp: bech32.hrp, version: version); @@ -231,9 +247,11 @@ abstract class Bech32Address implements Address { } class P2WPKHAddress extends Bech32Address { + static const int _expectedProgramLength = 20; + /// Constructs a P2WPKH address directly from the [hash] P2WPKHAddress.fromHash(Uint8List hash, {required String hrp}) - : super._(0, copyCheckBytes(hash, 20), hrp); + : super._(0, copyCheckBytes(hash, _expectedProgramLength), hrp); /// Constructs a P2WPKH address from a [pubkey] P2WPKHAddress.fromPublicKey(ECPublicKey pubkey, {required String hrp}) @@ -244,9 +262,11 @@ class P2WPKHAddress extends Bech32Address { } class P2WSHAddress extends Bech32Address { + static const int _expectedProgramLength = 32; + /// Constructs a P2WSH address from the script [hash] P2WSHAddress.fromHash(Uint8List hash, {required String hrp}) - : super._(0, copyCheckBytes(hash, 32), hrp); + : super._(0, copyCheckBytes(hash, _expectedProgramLength), hrp); /// Constructs a P2WSH address for a witnessScript P2WSHAddress.fromWitnessScript(Script script, {required String hrp}) @@ -257,8 +277,10 @@ class P2WSHAddress extends Bech32Address { } class P2TRAddress extends Bech32Address { + static const int _expectedProgramLength = 32; + P2TRAddress.fromTweakedKeyX(Uint8List tweakedKeyX, {required String hrp}) - : super._(1, copyCheckBytes(tweakedKeyX, 32), hrp); + : super._(1, copyCheckBytes(tweakedKeyX, _expectedProgramLength), hrp); P2TRAddress.fromTweakedKey(ECPublicKey tweakedKey, {required String hrp}) : super._(1, tweakedKey.x, hrp); @@ -270,6 +292,45 @@ class P2TRAddress extends Bech32Address { P2TR get program => P2TR.fromTweakedKeyX(_data); } +class MwebAddress extends Bech32Address { + static const int _expectedProgramLength = 66; + + factory MwebAddress.fromAddress(String encoded, {required Network network}) { + // TODO this should probably be handled differently as well + final mwebNetwork = network.copyWith(bech32Hrp: "ltcmweb"); + + // Delegate to the base Bech32Address.fromString, passing the customized network. + final Bech32Address decodedAddr = Bech32Address.fromString( + encoded, + mwebNetwork, + ); + + if (decodedAddr is! MwebAddress) { + throw Exception( + "Invalid MWEB address format or type: $encoded. Decoded as ${decodedAddr.runtimeType}", + ); + } + return decodedAddr; + } + + MwebAddress.fromHash({required Uint8List hash, required String hrp}) + : super._( + 0, + copyCheckBytes(hash, _expectedProgramLength), + hrp, + ignoreMaxLengthCheckForMweb: true, + ) { + if (data.length != _expectedProgramLength) { + throw Exception( + "Invalid MWEB witness program length: ${data.length}, expected $_expectedProgramLength", + ); + } + } + + @override + Program get program => RawProgram(Script([MwebScriptOp(data)])); +} + /// This address type is for all bech32 addresses that do not match known /// witness versions. class UnknownWitnessAddress extends Bech32Address { diff --git a/coinlib/lib/src/encode/bech32.dart b/coinlib/lib/src/encode/bech32.dart index de1a115..cc62c00 100644 --- a/coinlib/lib/src/encode/bech32.dart +++ b/coinlib/lib/src/encode/bech32.dart @@ -99,6 +99,7 @@ class Bech32 { required String hrp, required List words, required this.type, + bool ignoreMaxLengthCheckForMweb = false, }) : hrp = hrp.toLowerCase(), words = List.unmodifiable(words) { if (hrp.isEmpty) throw InvalidBech32("Missing HRP"); @@ -108,7 +109,8 @@ class Bech32 { throw InvalidBech32("Words outside of 5-bit range"); } - if (hrp.length + 1 + words.length + checksumLength > maxLength) { + if (!ignoreMaxLengthCheckForMweb && + hrp.length + 1 + words.length + checksumLength > maxLength) { throw InvalidBech32("Bech32 too long"); } } @@ -116,8 +118,11 @@ class Bech32 { /// Decodes a bech32 string into the hrp, 5-bit words and type. May throw an /// [InvalidBech32]. It will throw [InvalidBech32Checksum] if the bech32 is /// valid but doesn't have a valid checksum for either bech32 or bech32m. - factory Bech32.decode(String encoded) { - if (encoded.length > maxLength) { + factory Bech32.decode( + String encoded, { + bool ignoreMaxLengthCheckForMweb = false, + }) { + if (!ignoreMaxLengthCheckForMweb && encoded.length > maxLength) { throw InvalidBech32("Bech32 too long"); } diff --git a/coinlib/lib/src/scripts/programs/mweb.dart b/coinlib/lib/src/scripts/programs/mweb.dart new file mode 100644 index 0000000..d282a65 --- /dev/null +++ b/coinlib/lib/src/scripts/programs/mweb.dart @@ -0,0 +1,32 @@ +import 'dart:typed_data'; + +import '../../../coinlib.dart'; + +class MwebScriptOp implements ScriptOp { + final Uint8List data; + + MwebScriptOp(this.data); + + @override + String get asm => throw UnimplementedError(); + + @override + Uint8List get compiled => data; + + @override + ECDSAInputSignature? get ecdsaSig => null; + + @override + bool match(ScriptOp other) { + throw UnimplementedError(); + } + + @override + int? get number => null; + + @override + ECPublicKey? get publicKey => null; + + @override + SchnorrInputSignature? get schnorrSig => null; +} From ed2a45f20a3a44776c8fc5ce9cb2069bcfa19247 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 16 Jul 2026 21:58:42 -0800 Subject: [PATCH 4/6] some mweb related clean up --- coinlib/lib/src/address.dart | 36 +++++++++++--------- coinlib/lib/src/network.dart | 5 ++- coinlib/test/address_test.dart | 61 +++++++++++++++------------------- 3 files changed, 51 insertions(+), 51 deletions(-) diff --git a/coinlib/lib/src/address.dart b/coinlib/lib/src/address.dart index 3356aa2..de20a36 100644 --- a/coinlib/lib/src/address.dart +++ b/coinlib/lib/src/address.dart @@ -130,6 +130,8 @@ class P2SHAddress extends Base58Address { /// [P2WSHAddress]. Unknown witness programs are encoded via /// [UnknownWitnessAddress]. abstract class Bech32Address implements Address { + static const nonMwebMaxWitnessProgramLength = 40; + /// The human readable part of the address used to specify the network final String hrp; @@ -148,6 +150,14 @@ abstract class Bech32Address implements Address { throw ArgumentError("bech32 version must be 0-16", "this.version"); } + if (!ignoreMaxLengthCheckForMweb && + (_data.length < 2 || _data.length > nonMwebMaxWitnessProgramLength)) { + throw ArgumentError( + "witness programs must be 2-$nonMwebMaxWitnessProgramLength in length", + "this.program", + ); + } + if (!hrpValid(hrp)) { throw ArgumentError("Invalid hrp: $hrp", "this.hrp"); } @@ -172,11 +182,14 @@ abstract class Bech32Address implements Address { factory Bech32Address.fromString(String encoded, Network network) { final bech32 = Bech32.decode( encoded, - ignoreMaxLengthCheckForMweb: network.bech32Hrp == "ltcmweb", + ignoreMaxLengthCheckForMweb: network.mwebBech32Hrp.isNotEmpty, ); if (bech32.words.isEmpty) throw InvalidAddress(); - if (bech32.hrp != network.bech32Hrp) throw InvalidAddressNetwork(); + if (bech32.hrp != network.bech32Hrp && + bech32.hrp != network.mwebBech32Hrp) { + throw InvalidAddressNetwork(); + } final version = bech32.words[0]; @@ -193,13 +206,9 @@ abstract class Bech32Address implements Address { late Bech32Address addr; - // TODO account for testnet (don't hardcode this) - if (network.bech32Hrp == "ltcmweb" && version == 0) { - if (bytes.length != MwebAddress._expectedProgramLength) { - throw Exception( - "Invalid MWEB witness program length: ${bytes.length}, expected ${MwebAddress._expectedProgramLength}", - ); - } + if (network.mwebBech32Hrp.isNotEmpty && + version == 0 && + bytes.length == MwebAddress._expectedProgramLength) { addr = MwebAddress.fromHash(hash: bytes, hrp: bech32.hrp); } else if (version == 0) { // Version 0 signals P2WPKH or P2WSH @@ -219,7 +228,7 @@ abstract class Bech32Address implements Address { } } else if (version <= 16) { // Treat other versions as unknown, but still require valid length range (e.g., 2-40) - if (bytes.length < 2 || bytes.length > 40) { + if (bytes.length < 2 || bytes.length > nonMwebMaxWitnessProgramLength) { // Using 40 as a general max for unknown throw InvalidAddress(); } @@ -295,14 +304,11 @@ class P2TRAddress extends Bech32Address { class MwebAddress extends Bech32Address { static const int _expectedProgramLength = 66; - factory MwebAddress.fromAddress(String encoded, {required Network network}) { - // TODO this should probably be handled differently as well - final mwebNetwork = network.copyWith(bech32Hrp: "ltcmweb"); - + factory MwebAddress.fromString(String encoded, {required Network network}) { // Delegate to the base Bech32Address.fromString, passing the customized network. final Bech32Address decodedAddr = Bech32Address.fromString( encoded, - mwebNetwork, + network, ); if (decodedAddr is! MwebAddress) { diff --git a/coinlib/lib/src/network.dart b/coinlib/lib/src/network.dart index 12b88b0..47d2342 100644 --- a/coinlib/lib/src/network.dart +++ b/coinlib/lib/src/network.dart @@ -1,7 +1,7 @@ class Network { final int wifPrefix, p2pkhPrefix, p2shPrefix, privHDPrefix, pubHDPrefix; - final String bech32Hrp, messagePrefix; + final String bech32Hrp, messagePrefix, mwebBech32Hrp; final BigInt minFee, minOutput, feePerKb; Network({ @@ -11,6 +11,7 @@ class Network { required this.privHDPrefix, required this.pubHDPrefix, required this.bech32Hrp, + this.mwebBech32Hrp = "", required this.messagePrefix, required this.minFee, required this.minOutput, @@ -24,6 +25,7 @@ class Network { privHDPrefix: 0x0488ade4, pubHDPrefix: 0x0488b21e, bech32Hrp: "pc", + mwebBech32Hrp: "", messagePrefix: "Peercoin Signed Message:\n", minFee: BigInt.from(1000), minOutput: BigInt.from(10000), @@ -37,6 +39,7 @@ class Network { privHDPrefix: 0x043587CF, pubHDPrefix: 0x04358394, bech32Hrp: "tpc", + mwebBech32Hrp: "", messagePrefix: "Peercoin Signed Message:\n", minFee: BigInt.from(1000), minOutput: BigInt.from(10000), diff --git a/coinlib/test/address_test.dart b/coinlib/test/address_test.dart index 95ad137..e1b6d0e 100644 --- a/coinlib/test/address_test.dart +++ b/coinlib/test/address_test.dart @@ -1,6 +1,8 @@ import 'dart:typed_data'; + import 'package:coinlib/coinlib.dart'; import 'package:test/test.dart'; + import 'vectors/taproot.dart'; final wrongNetwork = Network( @@ -10,6 +12,7 @@ final wrongNetwork = Network( privHDPrefix: 0, pubHDPrefix: 0, bech32Hrp: "wrong", + mwebBech32Hrp: "", messagePrefix: "", feePerKb: BigInt.from(10000), minFee: BigInt.from(1000), @@ -43,9 +46,10 @@ void expectBech32Equal(Bech32Address addr, Bech32Address expected) { } void expectValidAddress( - String encoded, Network network, T expected, + String encoded, + Network network, + T expected, ) { - final baseDecoded = Address.fromString(encoded, network); expect(baseDecoded, isA()); @@ -78,13 +82,10 @@ void expectValidAddress( expectBech32Equal(b32Base, expected); expectBech32Equal(b32Sub, expected); } - } void main() { - group("Address", () { - late ECPublicKey pubkey; setUpAll(() async { await loadCoinlib(); @@ -94,7 +95,6 @@ void main() { }); test("valid P2PKH addresses", () { - expectValidAddress( "P8bB9yPr3vVByqfmM5KXftyGckAtAdu6f8", Network.mainnet, @@ -108,14 +108,13 @@ void main() { "PGkLtYrKeMDbBCaFy4yMRhN9ZTjJp2y8Pb", Network.mainnet, P2PKHAddress.fromPublicKey( - pubkey, version: Network.mainnet.p2pkhPrefix, + pubkey, + version: Network.mainnet.p2pkhPrefix, ), ); - }); test("valid P2SH addresses", () { - expectValidAddress( "pUtBBpAznHgPW9TDtWJcDo7qGXQJqnf1W9", Network.mainnet, @@ -133,11 +132,9 @@ void main() { version: Network.mainnet.p2shPrefix, ), ); - }); test("valid P2WPKH addresses", () { - expectValidAddress( "pc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqmtd2rq", Network.mainnet, @@ -151,14 +148,13 @@ void main() { "pc1qt97wqg464zrhnx23upykca5annqvwkwuvqmpk5", Network.mainnet, P2WPKHAddress.fromPublicKey( - pubkey, hrp: Network.mainnet.bech32Hrp, + pubkey, + hrp: Network.mainnet.bech32Hrp, ), ); - }); test("valid P2WSH addresses", () { - expectValidAddress( "pc1qlllllllllllllllllllllllllllllllllllllllllllllllllllsm5knxw", Network.mainnet, @@ -178,11 +174,9 @@ void main() { hrp: Network.mainnet.bech32Hrp, ), ); - }); test("valid P2TR addresses", () { - expectValidAddress( "pc1punvppl2stp38f7kwv2u2spltjuvuaayuqsthe34hd2dyy5w4g58qj5f0v2", Network.mainnet, @@ -213,11 +207,9 @@ void main() { hrp: Network.mainnet.bech32Hrp, ), ); - }); test("valid unknown witness addresses", () { - // 40 program bytes expectValidAddress( "pc1sqqqsyqcyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8qarc0jqgfzyvjz2f38pj2w3g", @@ -239,11 +231,9 @@ void main() { hrp: Network.mainnet.bech32Hrp, ), ); - }); test("invalid addresses", () { - for (final invalid in [ // Neither valid bech32 or base58 "", @@ -276,13 +266,13 @@ void main() { reason: invalid, ); } - }); test("invalid checksums", () { expect( () => Address.fromString( - "P8bB9yPr3vVByqfmM5KXftyGckAtAdu6f9", Network.mainnet, + "P8bB9yPr3vVByqfmM5KXftyGckAtAdu6f9", + Network.mainnet, ), throwsA(isA()), ); @@ -305,7 +295,6 @@ void main() { }); test("invalid version, program and hrp arguments", () { - // Too small program expect( () => UnknownWitnessAddress.fromHex( @@ -331,7 +320,7 @@ void main() { () => UnknownWitnessAddress.fromHex( "0001", version: 16, - hrp: "\x7f""1axkwrx", + hrp: "\x7f" "1axkwrx", ), throwsArgumentError, ); @@ -353,38 +342,41 @@ void main() { ), throwsArgumentError, ); - }); final longHrp = - "thishrpis78byteslongleadingthetotalsizetobe90characterswitheverythingincluded1"; + "thishrpis78byteslongleadingthetotalsizetobe90characterswitheverythingincluded1"; test("arguments correct size", () { - - final addr = UnknownWitnessAddress.fromHex("0001", version: 16, hrp: longHrp); + final addr = + UnknownWitnessAddress.fromHex("0001", version: 16, hrp: longHrp); expectValidAddress( "${longHrp}1sqqqs3t97ut", Network( - wifPrefix: 0, p2shPrefix: 0, p2pkhPrefix: 0, - privHDPrefix: 0, pubHDPrefix: 0, - bech32Hrp: longHrp, messagePrefix: "", + wifPrefix: 0, + p2shPrefix: 0, + p2pkhPrefix: 0, + privHDPrefix: 0, + pubHDPrefix: 0, + bech32Hrp: longHrp, + mwebBech32Hrp: "", + messagePrefix: "", feePerKb: BigInt.from(10000), minFee: BigInt.from(1000), minOutput: BigInt.from(10000), ), addr, ); - }); test("arguments too long", () { expect( - () => UnknownWitnessAddress.fromHex("0001", version: 16, hrp: "${longHrp}1"), + () => UnknownWitnessAddress.fromHex("0001", + version: 16, hrp: "${longHrp}1"), throwsArgumentError, ); }); - }); group("Base58Address", () { @@ -406,5 +398,4 @@ void main() { expect(addr.data, Uint8List(20)); }); }); - } From f373c29386a285ae6bbb5ae710f8ec703296cb9e Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 16 Jul 2026 21:58:42 -0800 Subject: [PATCH 5/6] Fix mwebAddress toString --- coinlib/lib/src/address.dart | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/coinlib/lib/src/address.dart b/coinlib/lib/src/address.dart index de20a36..d78a750 100644 --- a/coinlib/lib/src/address.dart +++ b/coinlib/lib/src/address.dart @@ -335,6 +335,17 @@ class MwebAddress extends Bech32Address { @override Program get program => RawProgram(Script([MwebScriptOp(data)])); + + @override + toString() => _encodedCache ??= Bech32( + hrp: hrp, + words: List.from([ + version, + ...convertBits(_data, 8, 5, true)!, + ]), + type: version == 0 ? Bech32Type.bech32 : Bech32Type.bech32m, + ignoreMaxLengthCheckForMweb: true, + ).encode(); } /// This address type is for all bech32 addresses that do not match known From 2c2d6e3306f2a420a65104ef22b1dcafe486f972 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 16 Jul 2026 22:01:13 -0800 Subject: [PATCH 6/6] feat: basic MWEB transaction read and write Support reading and writing transactions carrying MWEB extension data as used by Litecoin. The 0x08 flag bit alongside the witness marker signals MWEB data, which is carried as opaque bytes between the witness data and the locktime. mwebBytes is preserved through input/output modification copies and legacy sighash computation. Ported from cypherstack/coinlib commit 7723ba3, reworked to keep the existing expectWitness two-pass parsing API and adapted to the Locktime abstraction. --- .../tx/sighash/legacy_signature_hasher.dart | 1 + coinlib/lib/src/tx/transaction.dart | 41 ++++++++++++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/coinlib/lib/src/tx/sighash/legacy_signature_hasher.dart b/coinlib/lib/src/tx/sighash/legacy_signature_hasher.dart index daf0396..98eb61f 100644 --- a/coinlib/lib/src/tx/sighash/legacy_signature_hasher.dart +++ b/coinlib/lib/src/tx/sighash/legacy_signature_hasher.dart @@ -71,6 +71,7 @@ final class LegacySignatureHasher extends SignatureHasher { inputs: modifiedInputs, outputs: modifiedOutputs, locktime: tx.locktime, + mwebBytes: tx.mwebBytes, ); // Add sighash type onto the end diff --git a/coinlib/lib/src/tx/transaction.dart b/coinlib/lib/src/tx/transaction.dart index aefd7bb..6254e6b 100644 --- a/coinlib/lib/src/tx/transaction.dart +++ b/coinlib/lib/src/tx/transaction.dart @@ -47,6 +47,9 @@ class Transaction with Writable { final List inputs; final List outputs; final Locktime locktime; + /// Raw MWEB extension data carried between the witness data and locktime, + /// as used by Litecoin. This is kept as opaque bytes. + final Uint8List? mwebBytes; /// Constructs a transaction with the given [inputs] and [outputs]. /// [TransactionTooLarge] will be thrown if the resulting transction exceeds @@ -59,6 +62,7 @@ class Transaction with Writable { required Iterable inputs, required Iterable outputs, this.locktime = Locktime.zero, + this.mwebBytes, }) : inputs = List.unmodifiable(inputs), outputs = List.unmodifiable(outputs) @@ -77,11 +81,17 @@ class Transaction with Writable { final version = reader.readInt32(); + bool hasWitness = false; + bool hasMweb = false; if (witness) { - // Check for witness data + // Check for witness data. The 0x01 flag bit signals witness data and + // the 0x08 flag bit signals MWEB data as used by Litecoin. final marker = reader.readUInt8(); final flag = reader.readUInt8(); - if (marker != 0 || flag != 1) return null; + if (marker != 0) return null; + hasWitness = (flag & 1) != 0; + hasMweb = (flag & 8) != 0; + if (!hasWitness && !hasMweb) return null; } final rawInputs = List.generate( @@ -96,10 +106,19 @@ class Transaction with Writable { // Match the raw inputs with witness data if this is a witness transaction final inputs = rawInputs.map( - (raw) => Input.match(raw, witness ? reader.readVector() : []), + (raw) => Input.match(raw, hasWitness ? reader.readVector() : []), // Create list now to ensure we read the witness data before the locktime ).toList(); + // Any MWEB data is carried between the witness data and the locktime + Uint8List? mwebBytes; + if (hasMweb) { + final remaining = reader.bytes.lengthInBytes - reader.offset; + if (remaining > 4) { + mwebBytes = reader.readSlice(remaining - 4); + } + } + final locktime = reader.readUInt32(); return Transaction( @@ -107,6 +126,7 @@ class Transaction with Writable { inputs: inputs, outputs: outputs, locktime: Locktime(locktime), + mwebBytes: mwebBytes, ); } @@ -166,7 +186,7 @@ class Transaction with Writable { if (isWitness) { writer.writeUInt8(0); // Marker - writer.writeUInt8(1); // Flag + writer.writeUInt8(mwebBytes != null ? 9 : 1); // Flag } writer.writeVarInt(BigInt.from(inputs.length)); @@ -185,6 +205,10 @@ class Transaction with Writable { } } + if (mwebBytes != null) { + writer.writeSlice(mwebBytes!); + } + writer.writeUInt32(locktime.value); } @@ -194,6 +218,7 @@ class Transaction with Writable { inputs: newInputs, outputs: outputs, locktime: locktime, + mwebBytes: mwebBytes, ); T _requireInputOfType(int inputN) { @@ -346,6 +371,7 @@ class Transaction with Writable { ], outputs: outputs, locktime: locktime, + mwebBytes: mwebBytes, ); /// Returns a new [Transaction] with the [output] added to the end of the @@ -370,6 +396,7 @@ class Transaction with Writable { inputs: modifiedInputs, outputs: [...outputs, output], locktime: locktime, + mwebBytes: mwebBytes, ); } @@ -394,6 +421,7 @@ class Transaction with Writable { ), outputs: outputs, locktime: locktime, + mwebBytes: mwebBytes, ) : this; @@ -414,8 +442,9 @@ class Transaction with Writable { String get txid => bytesToHex(Uint8List.fromList(legacyHash.reversed.toList())); - /// If the transaction has any witness inputs. - bool get isWitness => inputs.any((input) => input is WitnessInput); + /// If the transaction has any witness inputs or carries MWEB data. + bool get isWitness + => inputs.any((input) => input is WitnessInput) || mwebBytes != null; bool get isCoinBase => inputs.length == 1