diff --git a/coinlib/lib/src/coinlib_base.dart b/coinlib/lib/src/coinlib_base.dart index 48750e5..60e6fa4 100644 --- a/coinlib/lib/src/coinlib_base.dart +++ b/coinlib/lib/src/coinlib_base.dart @@ -46,6 +46,7 @@ export 'package:coinlib/src/tx/inputs/pkh_input.dart'; export 'package:coinlib/src/tx/inputs/raw_input.dart'; export 'package:coinlib/src/tx/inputs/taproot_input.dart'; export 'package:coinlib/src/tx/inputs/taproot_key_input.dart'; +export 'package:coinlib/src/tx/inputs/taproot_script_input.dart'; export 'package:coinlib/src/tx/inputs/witness_input.dart'; export 'package:coinlib/src/tx/sighash/legacy_signature_hasher.dart'; diff --git a/coinlib/lib/src/taproot.dart b/coinlib/lib/src/taproot.dart index 378f22b..0fd44c7 100644 --- a/coinlib/lib/src/taproot.dart +++ b/coinlib/lib/src/taproot.dart @@ -160,10 +160,11 @@ class TapBranch implements TapNode { class TapLeaf with Writable implements TapNode { static final leafHash = getTaggedHasher("TapLeaf"); + static const int tapscriptVersion = 0xc0; /// The Tapscript version is fixed as 0xc0 as this is the only implemented and /// enforced version - final int version = 0xc0; + final int version = tapscriptVersion; final Script script; TapLeaf(this.script); diff --git a/coinlib/lib/src/tx/inputs/taproot_script_input.dart b/coinlib/lib/src/tx/inputs/taproot_script_input.dart new file mode 100644 index 0000000..6ac952b --- /dev/null +++ b/coinlib/lib/src/tx/inputs/taproot_script_input.dart @@ -0,0 +1,90 @@ +import 'dart:typed_data'; +import 'package:coinlib/src/common/serial.dart'; +import 'package:coinlib/src/scripts/operations.dart'; +import 'package:coinlib/src/scripts/script.dart'; +import 'package:coinlib/src/taproot.dart'; +import 'package:coinlib/src/tx/inputs/taproot_input.dart'; +import 'package:coinlib/src/tx/outpoint.dart'; +import 'input.dart'; +import 'raw_input.dart'; + +/// A [TaprootInput] which spends using the script-path for 0xc0 version +/// Tapscripts. There is no signing logic and sign() is not implemented. +/// Subclasses should handle signing. [createInputSignature] can be used to +/// create signatures for insertion as necessary. +class TaprootScriptInput extends TaprootInput { + + /// The tapscript embedded in the witness data, not to be confused with the + /// empty [script]. + final Script tapscript; + + TaprootScriptInput({ + required OutPoint prevOut, + required Uint8List controlBlock, + required this.tapscript, + List? stack, + int sequence = Input.sequenceFinal, + }) : super( + prevOut: prevOut, + sequence: sequence, + witness: [if (stack != null) ...stack, tapscript.compiled, controlBlock], + ); + + TaprootScriptInput.fromTaprootLeaf({ + required OutPoint prevOut, + required Taproot taproot, + required TapLeaf leaf, + List? stack, + int sequence = Input.sequenceFinal, + }) : this( + prevOut: prevOut, + controlBlock: taproot.controlBlockForLeaf(leaf), + tapscript: leaf.script, + stack: stack, + sequence: sequence, + ); + + /// Checks if the [raw] input and [witness] data match the expected format for + /// a [TaprootScriptInput] with the control block and script. If it matches + /// this returns a [TaprootScriptInput] for the input or else it returns null. + /// The script must be valid with minimal push data. The control block must be + /// the correct size and contain the correct 0xc0 tapscript version but the + /// internal key and parity bit is not validated. + static TaprootScriptInput? match(RawInput raw, List witness) { + + if (raw.scriptSig.isNotEmpty) return null; + if (witness.length < 2) return null; + + final controlBlock = witness.last; + final lengthAfterKey = controlBlock.length - 33; + + if ( + controlBlock.length < 33 + || lengthAfterKey % 32 != 0 + || lengthAfterKey / 32 > 128 + || controlBlock[0] & 0xfe != TapLeaf.tapscriptVersion + ) { + return null; + } + + try { + + return TaprootScriptInput( + prevOut: raw.prevOut, + controlBlock: controlBlock, + tapscript: Script.decompile(witness[witness.length-2]), + stack: witness.sublist(0, witness.length-2), + sequence: raw.sequence, + ); + + } on OutOfData { + return null; + } on PushDataNotMinimal { + return null; + } + + } + + Uint8List get controlBlock => witness.last; + +} diff --git a/coinlib/lib/src/tx/inputs/witness_input.dart b/coinlib/lib/src/tx/inputs/witness_input.dart index e1e889f..ae1908e 100644 --- a/coinlib/lib/src/tx/inputs/witness_input.dart +++ b/coinlib/lib/src/tx/inputs/witness_input.dart @@ -1,9 +1,10 @@ import 'dart:typed_data'; -import 'package:coinlib/src/tx/inputs/taproot_key_input.dart'; import 'package:coinlib/src/tx/outpoint.dart'; import 'input.dart'; import 'raw_input.dart'; import 'p2wpkh_input.dart'; +import 'taproot_key_input.dart'; +import 'taproot_script_input.dart'; /// The base-class for all witness inputs class WitnessInput extends RawInput { @@ -29,6 +30,7 @@ class WitnessInput extends RawInput { // Is a witness input, so match with the specific input type P2WPKHInput.match(raw, witness) ?? TaprootKeyInput.match(raw, witness) + ?? TaprootScriptInput.match(raw, witness) ?? WitnessInput( prevOut: raw.prevOut, witness: witness, diff --git a/coinlib/test/tx/inputs/p2wpkh_input_test.dart b/coinlib/test/tx/inputs/p2wpkh_input_test.dart index 1ec76b9..f5716fa 100644 --- a/coinlib/test/tx/inputs/p2wpkh_input_test.dart +++ b/coinlib/test/tx/inputs/p2wpkh_input_test.dart @@ -33,13 +33,6 @@ void main() { test("valid p2wpkh inputs inc. addSignature", () { - final rawBytes = Uint8List.fromList([ - ...prevOutHash, - 0xef, 0xbe, 0xed, 0xfe, - 0, - 0xed, 0xfe, 0xef, 0xbe, - ]); - expectP2WPKHInput(P2WPKHInput input, bool hasSig) { expectInput(input); @@ -56,8 +49,8 @@ void main() { } expect(input.witness, getWitness(hasSig)); - expect(input.size, rawBytes.length); - expect(input.toBytes(), rawBytes); + expect(input.size, rawWitnessInputBytes.length); + expect(input.toBytes(), rawWitnessInputBytes); } @@ -80,7 +73,7 @@ void main() { expectMatched(bool hasSig) { final matched = Input.match( - RawInput.fromReader(BytesReader(rawBytes)), + RawInput.fromReader(BytesReader(rawWitnessInputBytes)), getWitness(hasSig), ); expect(matched, isA()); diff --git a/coinlib/test/tx/inputs/taproot_key_input_test.dart b/coinlib/test/tx/inputs/taproot_key_input_test.dart index 3cee011..a846824 100644 --- a/coinlib/test/tx/inputs/taproot_key_input_test.dart +++ b/coinlib/test/tx/inputs/taproot_key_input_test.dart @@ -22,13 +22,6 @@ void main() { test("valid key-path taproot inputs inc. addSignature", () { - final rawBytes = Uint8List.fromList([ - ...prevOutHash, - 0xef, 0xbe, 0xed, 0xfe, - 0, - 0xed, 0xfe, 0xef, 0xbe, - ]); - expectTaprootKeyInput(TaprootKeyInput input, bool hasSig) { expectInput(input); @@ -44,8 +37,8 @@ void main() { } expect(input.witness, getWitness(hasSig)); - expect(input.size, rawBytes.length); - expect(input.toBytes(), rawBytes); + expect(input.size, rawWitnessInputBytes.length); + expect(input.toBytes(), rawWitnessInputBytes); } @@ -64,7 +57,7 @@ void main() { // Expect match only when there is a Schnorr signature present, as there // is no way to distinguish otherwise final matched = Input.match( - RawInput.fromReader(BytesReader(rawBytes)), + RawInput.fromReader(BytesReader(rawWitnessInputBytes)), getWitness(true), ); expect(matched, isA()); diff --git a/coinlib/test/tx/inputs/taproot_script_input_test.dart b/coinlib/test/tx/inputs/taproot_script_input_test.dart new file mode 100644 index 0000000..933d040 --- /dev/null +++ b/coinlib/test/tx/inputs/taproot_script_input_test.dart @@ -0,0 +1,133 @@ +import 'dart:typed_data'; +import 'package:coinlib/coinlib.dart'; +import 'package:test/test.dart'; +import '../../vectors/inputs.dart'; +import '../../vectors/taproot.dart'; + +// Placed in global for lazy initialisation after loadCoinlib +final taprootVec = taprootVectors[3]; +final controlBlockHex = taprootVec.controlBlocks[0]; +final controlBlock = hexToBytes(controlBlockHex); +final script = taprootVec.object.leaves[0].script; +final witness = [script.compiled, controlBlock]; +final stack = [hexToBytes("0102030405"), hexToBytes("01020304")]; + +void main() { + + group("TaprootScriptInput", () { + + setUpAll(loadCoinlib); + + test("valid script-path taproot inputs", () { + + expectTaprootScriptInput(TaprootScriptInput input, bool withStack) { + + expectInput(input); + + expect(input.complete, true); + expect(input.scriptSig.isEmpty, true); + expect(input.script!.length, 0); + + expect(input.tapscript.asm, script.asm); + expect(bytesToHex(input.controlBlock), controlBlockHex); + + expect(input.witness, [if (withStack) ...stack, ...witness]); + expect(input.size, rawWitnessInputBytes.length); + expect(input.toBytes(), rawWitnessInputBytes); + + } + + for (final withStack in [false, true]) { + expectTaprootScriptInput( + TaprootScriptInput( + prevOut: prevOut, + controlBlock: controlBlock, + tapscript: script, + sequence: sequence, + stack: withStack ? stack : null, + ), + withStack, + ); + expectTaprootScriptInput( + TaprootScriptInput.fromTaprootLeaf( + prevOut: prevOut, + taproot: taprootVec.object, + leaf: taprootVec.object.leaves[0], + sequence: sequence, + stack: withStack ? stack : null, + ), + withStack, + ); + expectTaprootScriptInput( + Input.match( + RawInput.fromReader(BytesReader(rawWitnessInputBytes)), + [if (withStack) ...stack, ...witness], + ) as TaprootScriptInput, + withStack, + ); + } + + }); + + test("control blocks up-to 128 hashes accepted", () { + expect( + TaprootScriptInput( + prevOut: prevOut, + controlBlock: Uint8List.fromList( + [...controlBlock.take(33), ...Uint8List(32*128)], + ), + tapscript: script, + ), + isA(), + ); + }); + + test("doesn't match non script-spend inputs", () { + + expectNoMatch(String asm, List witness) => expect( + TaprootScriptInput.match( + RawInput( + prevOut: prevOut, + scriptSig: Script.fromAsm(asm).compiled, + sequence: 0, + ), + witness, + ), + null, + ); + + expectNoMatch("0", witness); + // Requires script + expectNoMatch("", witness.skip(1).toList()); + // Not allowing annex + expectNoMatch("", [...witness, hexToBytes("5001020304")]); + // Control block must be correct size + expectNoMatch("", [script.compiled, hexToBytes("c1")]); + expectNoMatch("", [script.compiled, Uint8List(0)]); + expectNoMatch( + "", + [script.compiled, controlBlock.sublist(0, controlBlock.length-1)], + ); + expectNoMatch( + "", + [ + script.compiled, + Uint8List.fromList([...controlBlock.take(33), ...Uint8List(32*129)]), + ], + ); + // Control block must have valid tapscript version. + expectNoMatch( + "", + [ + script.compiled, + Uint8List.fromList([0xc2, ...controlBlock.sublist(1)]), + ], + ); + // Script must be valid and minimal + expectNoMatch("", [hexToBytes("0201"), controlBlock]); + expectNoMatch("", [hexToBytes("0101"), controlBlock]); + }); + + }); + +} diff --git a/coinlib/test/tx/inputs/witness_input_test.dart b/coinlib/test/tx/inputs/witness_input_test.dart index 59c4e45..7bae4b4 100644 --- a/coinlib/test/tx/inputs/witness_input_test.dart +++ b/coinlib/test/tx/inputs/witness_input_test.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import 'package:coinlib/coinlib.dart'; import 'package:test/test.dart'; +import '../../vectors/inputs.dart'; void main() { @@ -10,13 +11,7 @@ void main() { final prevOutN = 0xfeedbeef; final sequence = 0xbeeffeed; - final rawBytes = Uint8List.fromList([ - ...prevOutHash, - 0xef, 0xbe, 0xed, 0xfe, - 0, - 0xed, 0xfe, 0xef, 0xbe, - ]); - final raw = RawInput.fromReader(BytesReader(rawBytes)); + final raw = RawInput.fromReader(BytesReader(rawWitnessInputBytes)); final witness = [Uint8List.fromList([0, 1, 0xff])]; test("matches witness inputs", () { diff --git a/coinlib/test/vectors/inputs.dart b/coinlib/test/vectors/inputs.dart index 05d7085..85114aa 100644 --- a/coinlib/test/vectors/inputs.dart +++ b/coinlib/test/vectors/inputs.dart @@ -7,6 +7,13 @@ final prevOutN = 0xfeedbeef; final prevOut = OutPoint(prevOutHash, prevOutN); final sequence = 0xbeeffeed; +final rawWitnessInputBytes = Uint8List.fromList([ + ...prevOutHash, + 0xef, 0xbe, 0xed, 0xfe, + 0, + 0xed, 0xfe, 0xef, 0xbe, +]); + expectInput(Input input) { expect(input.prevOut.hash, prevOutHash); expect(input.prevOut.n, prevOutN);