From 8ec354e40826fc5180794a3365d45f947d109f09 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 3 Mar 2026 09:48:23 -0600 Subject: [PATCH 1/3] feat: add P2SHP2WPKHInput for BIP49 (P2SH-P2WPKH) inputs --- .../lib/src/tx/inputs/p2sh_p2wpkh_input.dart | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 coinlib/lib/src/tx/inputs/p2sh_p2wpkh_input.dart diff --git a/coinlib/lib/src/tx/inputs/p2sh_p2wpkh_input.dart b/coinlib/lib/src/tx/inputs/p2sh_p2wpkh_input.dart new file mode 100644 index 0000000..770ba39 --- /dev/null +++ b/coinlib/lib/src/tx/inputs/p2sh_p2wpkh_input.dart @@ -0,0 +1,134 @@ +import 'dart:typed_data'; +import 'package:coinlib/src/crypto/ec_private_key.dart'; +import 'package:coinlib/src/crypto/ec_public_key.dart'; +import 'package:coinlib/src/crypto/ecdsa_signature.dart'; +import 'package:coinlib/src/crypto/hash.dart'; +import 'package:coinlib/src/scripts/operations.dart'; +import 'package:coinlib/src/scripts/programs/p2wpkh.dart'; +import 'package:coinlib/src/scripts/script.dart'; +import 'package:coinlib/src/tx/sighash/witness_signature_hasher.dart'; +import 'package:coinlib/src/tx/sign_details.dart'; +import 'input.dart'; +import 'input_signature.dart'; +import 'pkh_input.dart'; +import 'raw_input.dart'; + +/// An input for a Pay-to-Script-Hash wrapping Pay-to-Witness-Public-Key-Hash +/// output (P2SH-P2WPKH / BIP49). +/// +/// The scriptSig contains only the push of the redeemScript (the P2WPKH +/// witness program), and the witness contains the signature and public key. +/// Signing uses the BIP143 witness sighash algorithm (same as P2WPKH). +class P2SHP2WPKHInput extends RawInput with PKHInput { + @override + final ECPublicKey publicKey; + + @override + final ECDSAInputSignature? insig; + + /// Witness stack: empty when unsigned, or [sig, pubkey] when signed. + final List witness; + + // The redeemScript is the compiled P2WPKH witness program: OP_0 <20-byte-pkHash> + static Uint8List _makeScriptSig(ECPublicKey publicKey) { + final redeemScript = P2WPKH.fromPublicKey(publicKey).script.compiled; + return Script([ScriptPushData(redeemScript)]).compiled; + } + + P2SHP2WPKHInput({ + required super.prevOut, + required this.publicKey, + this.insig, + super.sequence = Input.sequenceFinal, + }) : witness = insig != null + ? List.unmodifiable([insig.bytes, publicKey.data]) + : const [], + super(scriptSig: _makeScriptSig(publicKey)); + + @override + P2SHP2WPKHInput addSignature(ECDSAInputSignature insig) => P2SHP2WPKHInput( + prevOut: prevOut, + publicKey: publicKey, + insig: insig, + sequence: sequence, + ); + + @override + P2SHP2WPKHInput filterSignatures( + bool Function(InputSignature insig) predicate, + ) => + insig == null || predicate(insig!) + ? this + : P2SHP2WPKHInput( + prevOut: prevOut, + publicKey: publicKey, + sequence: sequence, + ); + + /// Signs the input using the BIP143 witness sighash algorithm. + P2SHP2WPKHInput sign({ + required LegacyWitnessSignDetails details, + required ECPrivateKey key, + }) { + checkKey(key); + final detailsWithScript = details.addScript(scriptCode); + final sig = ECDSAInputSignature( + ECDSASignature.sign(key, WitnessSignatureHasher(detailsWithScript).hash), + detailsWithScript.hashType, + ); + return addSignature(sig); + } + + /// Attempts to match a [RawInput] and its [witness] data as a + /// [P2SHP2WPKHInput]. Returns null if the format doesn't match. + static P2SHP2WPKHInput? match(RawInput raw, List witness) { + // Must have a non-empty scriptSig and 0-2 witness items + if (raw.scriptSig.isEmpty) return null; + if (witness.length > 2) return null; + + try { + final script = Script.decompile(raw.scriptSig); + final ops = script.ops; + // scriptSig must be exactly one push data (the redeemScript) + if (ops.length != 1 || ops[0] is! ScriptPushData) return null; + + final pushed = (ops[0] as ScriptPushData).data; + + // The pushed data must be a valid P2WPKH witness program + late P2WPKH p2wpkh; + try { + p2wpkh = P2WPKH.decompile(pushed); + } catch (_) { + return null; + } + + if (witness.isEmpty) return null; + + final pubkey = ECPublicKey(witness.last); + + // Verify pubkey hash matches the P2WPKH program + if (!_bytesEqual(hash160(pubkey.data), p2wpkh.pkHash)) return null; + + final insig = witness.length == 2 + ? ECDSAInputSignature.fromBytes(witness[0]) + : null; + + return P2SHP2WPKHInput( + prevOut: raw.prevOut, + sequence: raw.sequence, + publicKey: pubkey, + insig: insig, + ); + } catch (_) { + return null; + } + } +} + +bool _bytesEqual(Uint8List a, Uint8List b) { + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +} From c47eba97c2ec20f8c8184b4e055d6b504083582a Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 3 Mar 2026 09:49:38 -0600 Subject: [PATCH 2/3] feat: wire P2SHP2WPKHInput into Transaction --- coinlib/lib/src/tx/transaction.dart | 40 ++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/coinlib/lib/src/tx/transaction.dart b/coinlib/lib/src/tx/transaction.dart index 53c787d..b022c5a 100644 --- a/coinlib/lib/src/tx/transaction.dart +++ b/coinlib/lib/src/tx/transaction.dart @@ -12,6 +12,7 @@ import 'inputs/input.dart'; import 'inputs/input_signature.dart'; import 'inputs/legacy_input.dart'; import 'inputs/legacy_witness_input.dart'; +import 'inputs/p2sh_p2wpkh_input.dart'; import 'inputs/raw_input.dart'; import 'inputs/witness_input.dart'; import 'output.dart'; @@ -168,7 +169,13 @@ class Transaction with Writable { if (isWitness) { for (final input in inputs) { - writer.writeVector(input is WitnessInput ? input.witness : []); + if (input is WitnessInput) { + writer.writeVector(input.witness); + } else if (input is P2SHP2WPKHInput) { + writer.writeVector(input.witness); + } else { + writer.writeVector([]); + } } } @@ -243,6 +250,28 @@ class Transaction with Writable { ), ); + /// Sign a [P2SHP2WPKHInput] (BIP49 / P2SH-P2WPKH) at [inputN] with the + /// [key]. Must contain the [value] being spent. Uses the BIP143 witness + /// sighash. The signature hash type is SIGHASH_ALL by default. + Transaction signP2SHP2WPKH({ + required int inputN, + required ECPrivateKey key, + required BigInt value, + SigHashType hashType = const SigHashType.all(), + }) => + _replaceNewlySigned( + inputN, + _requireInputOfType(inputN).sign( + details: LegacyWitnessSignDetails( + tx: this, + inputN: inputN, + value: value, + hashType: hashType, + ), + key: key, + ), + ); + /// Sign a [TaprootKeyInput] at [inputN] with the tweaked [key]. /// /// If all inputs are included, all previous outputs must be provided to @@ -394,7 +423,8 @@ class Transaction with Writable { inputs: inputs.map( // Raw inputs remove all witness data and are serialized as legacy // inputs. Don't waste creating a new object for non-witness inputs. - (input) => input is WitnessInput + // P2SHP2WPKHInput keeps its scriptSig but drops witness data. + (input) => (input is WitnessInput || input is P2SHP2WPKHInput) ? RawInput( prevOut: input.prevOut, scriptSig: input.scriptSig, @@ -428,9 +458,11 @@ class Transaction with Writable { String get txid => bytesToHex(Uint8List.fromList(legacyHash.reversed.toList())); - /// If the transaction has any witness inputs. + /// If the transaction has any witness inputs (including P2SH-P2WPKH). bool get isWitness => - inputs.any((input) => input is WitnessInput) || mwebBytes != null; + inputs + .any((input) => input is WitnessInput || input is P2SHP2WPKHInput) || + mwebBytes != null; bool get isCoinBase => inputs.length == 1 && inputs.first.prevOut.coinbase && outputs.isNotEmpty; From cdfa62dd81eff78f83cdfa60332375db0011f229 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 3 Mar 2026 09:50:36 -0600 Subject: [PATCH 3/3] feat: register P2SHP2WPKHInput in Input.match and export from library --- coinlib/lib/src/coinlib_base.dart | 1 + coinlib/lib/src/tx/inputs/input.dart | 2 + .../tx/inputs/p2sh_p2wpkh_input_test.dart | 226 ++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 coinlib/test/tx/inputs/p2sh_p2wpkh_input_test.dart diff --git a/coinlib/lib/src/coinlib_base.dart b/coinlib/lib/src/coinlib_base.dart index 5de9a37..a4c865b 100644 --- a/coinlib/lib/src/coinlib_base.dart +++ b/coinlib/lib/src/coinlib_base.dart @@ -48,6 +48,7 @@ export 'package:coinlib/src/tx/inputs/legacy_input.dart'; export 'package:coinlib/src/tx/inputs/legacy_witness_input.dart'; export 'package:coinlib/src/tx/inputs/p2pkh_input.dart'; export 'package:coinlib/src/tx/inputs/p2sh_multisig_input.dart'; +export 'package:coinlib/src/tx/inputs/p2sh_p2wpkh_input.dart'; export 'package:coinlib/src/tx/inputs/p2wpkh_input.dart'; export 'package:coinlib/src/tx/inputs/pkh_input.dart'; export 'package:coinlib/src/tx/inputs/raw_input.dart'; diff --git a/coinlib/lib/src/tx/inputs/input.dart b/coinlib/lib/src/tx/inputs/input.dart index 2a4084e..7f1dafd 100644 --- a/coinlib/lib/src/tx/inputs/input.dart +++ b/coinlib/lib/src/tx/inputs/input.dart @@ -7,6 +7,7 @@ import 'package:coinlib/src/tx/outpoint.dart'; import 'input_signature.dart'; import 'p2pkh_input.dart'; import 'p2sh_multisig_input.dart'; +import 'p2sh_p2wpkh_input.dart'; import 'raw_input.dart'; import 'witness_input.dart'; @@ -37,6 +38,7 @@ abstract class Input with Writable { P2PKHInput.match(raw) ?? P2SHMultisigInput.match(raw) ?? WitnessInput.match(raw, witness) ?? + P2SHP2WPKHInput.match(raw, witness) ?? raw; /// Removes signatures that the [predicate] returns false for. This is used to diff --git a/coinlib/test/tx/inputs/p2sh_p2wpkh_input_test.dart b/coinlib/test/tx/inputs/p2sh_p2wpkh_input_test.dart new file mode 100644 index 0000000..d95dcb6 --- /dev/null +++ b/coinlib/test/tx/inputs/p2sh_p2wpkh_input_test.dart @@ -0,0 +1,226 @@ +import 'dart:typed_data'; +import 'package:coinlib/coinlib.dart'; +import 'package:test/test.dart'; +import '../../vectors/keys.dart'; +import '../../vectors/signatures.dart'; +import '../../vectors/inputs.dart'; +import '../../vectors/tx.dart'; + +void main() { + group("P2SHP2WPKHInput", () { + // Private key = 1, compressed + // Public key = 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 + // HASH160(pubkey) = 751e76e8199196d454941c45d1b3a323f1433bd6 + // redeemScript (P2WPKH) = 0014751e76e8199196d454941c45d1b3a323f1433bd6 (22 bytes) + // scriptSig = push(redeemScript) = 160014751e76e8199196d454941c45d1b3a323f1433bd6 (23 bytes) + final expectedScriptSigHex = + "160014751e76e8199196d454941c45d1b3a323f1433bd6"; + + final der = validDerSigs[0]; + final pkBytes = hexToBytes(pubkeyVec); + late ECPublicKey pk; + late ECPrivateKey privKey; + late ECDSAInputSignature insig; + + setUpAll(() async { + await loadCoinlib(); + pk = ECPublicKey(pkBytes); + privKey = keyPairVectors[0].privateObj; // private key = 1 + insig = ECDSAInputSignature( + ECDSASignature.fromDerHex(der), + SigHashType.none(), + ); + }); + + getWitness(bool hasSig) => [ + if (hasSig) + Uint8List.fromList([ + ...hexToBytes(der), + SigHashType.none().value, + ]), + hexToBytes(pubkeyVec), + ]; + + test("constructs correct scriptSig and witness", () { + final noSig = P2SHP2WPKHInput( + prevOut: prevOut, + sequence: sequence, + publicKey: pk, + ); + + // scriptSig should be the push of the P2WPKH redeemScript + expect(bytesToHex(noSig.scriptSig), expectedScriptSigHex); + expect(noSig.witness, isEmpty); + expect(noSig.complete, false); + expect(noSig.insig, isNull); + + final withSig = P2SHP2WPKHInput( + prevOut: prevOut, + sequence: sequence, + publicKey: pk, + insig: insig, + ); + + expect(bytesToHex(withSig.scriptSig), expectedScriptSigHex); + expect(withSig.witness, getWitness(true)); + expect(withSig.complete, true); + expect(withSig.insig, isNotNull); + }); + + test("addSignature returns new input with witness data", () { + final noSig = P2SHP2WPKHInput( + prevOut: prevOut, + sequence: sequence, + publicKey: pk, + ); + + final withSig = noSig.addSignature(insig); + + expect(bytesToHex(withSig.scriptSig), expectedScriptSigHex); + expect(withSig.witness, getWitness(true)); + expect(withSig.complete, true); + + // Original unchanged + expect(noSig.complete, false); + }); + + test("filterSignatures removes signature", () { + final withSig = P2SHP2WPKHInput( + prevOut: prevOut, + publicKey: pk, + insig: insig, + ); + + expect(withSig.filterSignatures((s) => false).insig, isNull); + expect(withSig.filterSignatures((s) => true).insig, isNotNull); + }); + + test("match recognises P2SH-P2WPKH format", () { + // Build raw input with the P2SH-P2WPKH scriptSig + final scriptSigBytes = hexToBytes(expectedScriptSigHex); + final raw = RawInput( + prevOut: prevOut, + scriptSig: scriptSigBytes, + sequence: sequence, + ); + + // Should match with witness (signed) + final matchedSigned = Input.match(raw, getWitness(true)); + expect(matchedSigned, isA()); + final input = matchedSigned as P2SHP2WPKHInput; + expect(input.complete, true); + expect(bytesToHex(input.scriptSig), expectedScriptSigHex); + expect(input.publicKey.hex, pubkeyVec); + expect(input.insig, isNotNull); + + // Should also match unsigned (just pubkey in witness) + final matchedUnsigned = Input.match(raw, getWitness(false)); + expect(matchedUnsigned, isA()); + expect((matchedUnsigned as P2SHP2WPKHInput).complete, false); + }); + + test("doesn't match non-P2SH-P2WPKH inputs", () { + expectNoMatch(Uint8List scriptSig, List witness) => expect( + P2SHP2WPKHInput.match( + RawInput( + prevOut: prevOut, scriptSig: scriptSig, sequence: sequence), + witness, + ), + isNull, + ); + + // Empty scriptSig (native witness, not P2SH) + expectNoMatch(Uint8List(0), getWitness(true)); + + // Wrong scriptSig: too many ops + expectNoMatch( + Script.fromAsm("0 0").compiled, + getWitness(true), + ); + + // Wrong pushed data: not a P2WPKH script + expectNoMatch( + Script([ScriptPushData(Uint8List(22))]).compiled, + getWitness(true), + ); + + // No witness + expectNoMatch(hexToBytes(expectedScriptSigHex), []); + + // Too many witness items + expectNoMatch( + hexToBytes(expectedScriptSigHex), + [...getWitness(true), Uint8List(33)], + ); + }); + + test("sign produces valid witness sighash (BIP143)", () { + // Build a minimal transaction with one P2SHP2WPKHInput and one output + final utxoValue = BigInt.from(100000); + + var tx = Transaction( + version: 1, + inputs: [ + P2SHP2WPKHInput( + prevOut: examplePrevOut, + publicKey: pk, + ), + ], + outputs: [exampleOutput], + ); + + // Sign using the witness sighash method + tx = tx.signP2SHP2WPKH( + inputN: 0, + key: privKey, + value: utxoValue, + ); + + final signedInput = tx.inputs[0] as P2SHP2WPKHInput; + + // Input should now be complete + expect(signedInput.complete, true); + expect(signedInput.insig, isNotNull); + + // scriptSig unchanged (still push of redeemScript) + expect(bytesToHex(signedInput.scriptSig), expectedScriptSigHex); + + // Witness should have [sig, pubkey] + expect(signedInput.witness.length, 2); + expect(signedInput.witness[1], pk.data); + + // Transaction should be serialised as a witness transaction + expect(tx.isWitness, true); + + // legacy (txid) serialisation should not contain witness data + final legacy = tx.legacy; + expect(legacy.isWitness, false); + expect(legacy.inputs[0], isA()); + // The legacy input's scriptSig should still be the redeemScript push + expect(bytesToHex(legacy.inputs[0].scriptSig), expectedScriptSigHex); + + // Re-serialise and parse: signed input should survive round-trip + final reserialised = Transaction.fromBytes(tx.toBytes()); + expect(reserialised.inputs[0], isA()); + final roundTripped = reserialised.inputs[0] as P2SHP2WPKHInput; + expect(roundTripped.complete, true); + expect(bytesToHex(roundTripped.scriptSig), expectedScriptSigHex); + expect(roundTripped.publicKey.hex, pubkeyVec); + }); + + test("isWitness is true for transactions with P2SHP2WPKHInput", () { + final tx = Transaction( + version: 1, + inputs: [ + P2SHP2WPKHInput( + prevOut: examplePrevOut, + publicKey: pk, + ), + ], + outputs: [exampleOutput], + ); + + expect(tx.isWitness, true); + }); + }); +}