diff --git a/coinlib/lib/src/coinlib_base.dart b/coinlib/lib/src/coinlib_base.dart index 2e99e2d..48750e5 100644 --- a/coinlib/lib/src/coinlib_base.dart +++ b/coinlib/lib/src/coinlib_base.dart @@ -37,11 +37,15 @@ export 'package:coinlib/src/tx/output.dart'; export 'package:coinlib/src/tx/inputs/input.dart'; export 'package:coinlib/src/tx/inputs/input_signature.dart'; +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/p2wpkh_input.dart'; 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/witness_input.dart'; export 'package:coinlib/src/tx/sighash/legacy_signature_hasher.dart'; diff --git a/coinlib/lib/src/tx/inputs/taproot_input.dart b/coinlib/lib/src/tx/inputs/taproot_input.dart new file mode 100644 index 0000000..0845de6 --- /dev/null +++ b/coinlib/lib/src/tx/inputs/taproot_input.dart @@ -0,0 +1,58 @@ +import 'dart:typed_data'; +import 'package:coinlib/src/crypto/ec_private_key.dart'; +import 'package:coinlib/src/crypto/schnorr_signature.dart'; +import 'package:coinlib/src/tx/output.dart'; +import 'package:coinlib/src/tx/sighash/sighash_type.dart'; +import 'package:coinlib/src/tx/sighash/taproot_signature_hasher.dart'; +import 'package:coinlib/src/tx/transaction.dart'; +import 'input.dart'; +import 'input_signature.dart'; +import 'witness_input.dart'; + +/// Represents v1 Taproot program inputs +abstract class TaprootInput extends WitnessInput { + + TaprootInput({ + required super.prevOut, + required super.witness, + super.sequence = Input.sequenceFinal, + }); + + /// Signs the input given the [tx], input number ([inputN]), private [key] and + /// [prevOuts] using the specifified [hashType]. Should throw + /// [CannotSignInput] if the key cannot sign the input. Implemented by + /// specific subclasses. + TaprootInput sign({ + required Transaction tx, + required int inputN, + required ECPrivateKey key, + required List prevOuts, + hashType = const SigHashType.all(), + }) => throw CannotSignInput("Unimplemented sign() for {this.runtimeType}"); + + /// Creates a signature for the input. Used by subclasses to implement + /// signing. + SchnorrInputSignature createInputSignature({ + required Transaction tx, + required int inputN, + required ECPrivateKey key, + required List prevOuts, + hashType = const SigHashType.all(), + Uint8List? leafHash, + int codeSeperatorPos = 0xFFFFFFFF, + }) => SchnorrInputSignature( + SchnorrSignature.sign( + key, + TaprootSignatureHasher( + tx: tx, + inputN: inputN, + prevOuts: prevOuts, + hashType: hashType, + leafHash: leafHash, + codeSeperatorPos: codeSeperatorPos, + ).hash, + ), + hashType, + ); + +} diff --git a/coinlib/lib/src/tx/inputs/taproot_key_input.dart b/coinlib/lib/src/tx/inputs/taproot_key_input.dart new file mode 100644 index 0000000..0cb919f --- /dev/null +++ b/coinlib/lib/src/tx/inputs/taproot_key_input.dart @@ -0,0 +1,106 @@ +import 'dart:typed_data'; +import 'package:coinlib/src/crypto/ec_private_key.dart'; +import 'package:coinlib/src/scripts/programs/p2tr.dart'; +import 'package:coinlib/src/taproot.dart'; +import 'package:coinlib/src/tx/inputs/taproot_input.dart'; +import 'package:coinlib/src/tx/outpoint.dart'; +import 'package:coinlib/src/tx/output.dart'; +import 'package:coinlib/src/tx/sighash/sighash_type.dart'; +import 'package:coinlib/src/tx/transaction.dart'; +import 'input.dart'; +import 'input_signature.dart'; +import 'raw_input.dart'; + +/// A [TaprootInput] which spends using the key-path +class TaprootKeyInput extends TaprootInput { + + final SchnorrInputSignature? insig; + + TaprootKeyInput({ + required OutPoint prevOut, + this.insig, + int sequence = Input.sequenceFinal, + }) : super( + prevOut: prevOut, + sequence: sequence, + witness: [if (insig != null) insig.bytes], + ); + + /// Checks if the [raw] input and [witness] data match the expected format for + /// a [TaprootKeyInput], with a signature. If it does it returns a + /// [TaprootKeyInput] for the input or else it returns null. + static TaprootKeyInput? match(RawInput raw, List witness) { + + if (raw.scriptSig.isNotEmpty) return null; + if (witness.length != 1) return null; + + try { + return TaprootKeyInput( + prevOut: raw.prevOut, + insig: SchnorrInputSignature.fromBytes(witness[0]), + sequence: raw.sequence, + ); + } on InvalidInputSignature { + return null; + } + + } + + @override + /// Return a signed Taproot input using tweaked private key for the key-path + /// spend. The [key] should be tweaked by [Taproot.tweakScalar]. + TaprootKeyInput sign({ + required Transaction tx, + required int inputN, + required ECPrivateKey key, + required List prevOuts, + hashType = const SigHashType.all(), + }) { + + if (inputN >= prevOuts.length) { + throw CannotSignInput( + "Input is out of range of the previous outputs provided", + ); + } + + // Check key corresponds to matching prevOut + final program = prevOuts[inputN].program; + if (program is! P2TR || key.pubkey.xonly != program.tweakedKey) { + throw CannotSignInput( + "Key cannot sign for Taproot input's tweaked key", + ); + } + + return addSignature( + createInputSignature( + tx: tx, + inputN: inputN, + key: key, + prevOuts: prevOuts, + hashType: hashType, + ), + ); + + } + + /// Returns a new [TaprootKeyInput] with the [SchnorrInputSignature] added. + /// Any existing signature is replaced. + TaprootKeyInput addSignature(SchnorrInputSignature insig) => TaprootKeyInput( + prevOut: prevOut, + insig: insig, + sequence: sequence, + ); + + @override + TaprootKeyInput filterSignatures( + bool Function(InputSignature insig) predicate, + ) => insig == null || predicate(insig!) ? this : TaprootKeyInput( + prevOut: prevOut, + insig: null, + sequence: sequence, + ); + + @override + bool get complete => insig != null; + +} diff --git a/coinlib/lib/src/tx/inputs/witness_input.dart b/coinlib/lib/src/tx/inputs/witness_input.dart index 90626ff..e1e889f 100644 --- a/coinlib/lib/src/tx/inputs/witness_input.dart +++ b/coinlib/lib/src/tx/inputs/witness_input.dart @@ -1,4 +1,5 @@ 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'; @@ -27,6 +28,7 @@ class WitnessInput extends RawInput { ? ( // Is a witness input, so match with the specific input type P2WPKHInput.match(raw, witness) + ?? TaprootKeyInput.match(raw, witness) ?? WitnessInput( prevOut: raw.prevOut, witness: witness, diff --git a/coinlib/lib/src/tx/transaction.dart b/coinlib/lib/src/tx/transaction.dart index 7b30ddb..b304a12 100644 --- a/coinlib/lib/src/tx/transaction.dart +++ b/coinlib/lib/src/tx/transaction.dart @@ -4,6 +4,7 @@ import 'package:coinlib/src/common/hex.dart'; import 'package:coinlib/src/common/serial.dart'; import 'package:coinlib/src/crypto/ec_private_key.dart'; import 'package:coinlib/src/crypto/hash.dart'; +import 'package:coinlib/src/tx/inputs/taproot_key_input.dart'; import 'inputs/input.dart'; import 'inputs/legacy_input.dart'; import 'inputs/legacy_witness_input.dart'; @@ -185,11 +186,13 @@ class Transaction with Writable { /// [Transaction] with the signed input. The input must be a signable /// P2PKH, P2WPKH or P2SH multisig input or [CannotSignInput] will be thrown. /// [value] is only required for P2WPKH. + /// [prevOuts] is only required for Taproot inputs. Transaction sign({ required int inputN, required ECPrivateKey key, hashType = const SigHashType.all(), BigInt? value, + List? prevOuts, }) { if (inputN >= inputs.length) { @@ -226,6 +229,28 @@ class Transaction with Writable { hashType: hashType, ); + } else if (input is TaprootKeyInput) { + + if (prevOuts == null) { + throw CannotSignInput( + "Previous outputs are required when signing a taproot input", + ); + } + + if (prevOuts.length != inputs.length) { + throw CannotSignInput( + "The number of previous outputs must match the number of inputs", + ); + } + + signedIn = input.sign( + tx: this, + inputN: inputN, + key: key, + prevOuts: prevOuts, + hashType: hashType, + ); + } else { throw CannotSignInput("${input.runtimeType} not a signable input"); } diff --git a/coinlib/test/tx/inputs/taproot_key_input_test.dart b/coinlib/test/tx/inputs/taproot_key_input_test.dart new file mode 100644 index 0000000..3cee011 --- /dev/null +++ b/coinlib/test/tx/inputs/taproot_key_input_test.dart @@ -0,0 +1,115 @@ +import 'dart:typed_data'; +import 'package:coinlib/coinlib.dart'; +import 'package:test/test.dart'; +import '../../vectors/signatures.dart'; +import '../../vectors/inputs.dart'; + +void main() { + + group("TaprootKeyInput", () { + + late SchnorrInputSignature insig; + + setUpAll(() async { + await loadCoinlib(); + insig = SchnorrInputSignature( + SchnorrSignature.fromHex(validSchnorrSig), + SigHashType.none(), + ); + }); + + getWitness(bool hasSig) => [if (hasSig) insig.bytes]; + + 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); + + expect(input.complete, hasSig); + expect(input.insig, hasSig ? isNotNull : null); + expect(input.scriptSig.isEmpty, true); + expect(input.script!.length, 0); + + if (hasSig) { + expect(bytesToHex(input.insig!.signature.data), validSchnorrSig); + expect(input.insig!.hashType.none, true); + } + + expect(input.witness, getWitness(hasSig)); + expect(input.size, rawBytes.length); + expect(input.toBytes(), rawBytes); + + } + + final noSig = TaprootKeyInput(prevOut: prevOut, sequence: sequence); + + final withSig = TaprootKeyInput( + prevOut: prevOut, + sequence: sequence, + insig: insig, + ); + + expectTaprootKeyInput(noSig, false); + expectTaprootKeyInput(withSig, true); + expectTaprootKeyInput(noSig.addSignature(insig), true); + + // 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)), + getWitness(true), + ); + expect(matched, isA()); + expectTaprootKeyInput(matched as TaprootKeyInput, true); + + }); + + test("doesn't match non key-spend inputs", () { + + expectNoMatch(String asm, List witness) => expect( + TaprootKeyInput.match( + RawInput( + prevOut: prevOut, + scriptSig: Script.fromAsm(asm).compiled, + sequence: 0, + ), + witness, + ), + null, + ); + + expectNoMatch("0", getWitness(true)); + // Doesn't match without signature + expectNoMatch("", getWitness(false)); + expectNoMatch("", [...getWitness(true), ...getWitness(true)]); + // Not allowing annex + expectNoMatch("", [...getWitness(true), hexToBytes("5001020304")]); + expectNoMatch( + "", + [ + Uint8List.fromList([ + ...hexToBytes(validDerSigs[0]), + SigHashType.noneValue, + ]), + ], + ); + + }); + + test("filterSignatures", () { + final input = TaprootKeyInput(prevOut: prevOut, insig: insig); + expect(input.filterSignatures((insig) => false).insig, isNull); + expect(input.filterSignatures((insig) => true).insig, isNotNull); + }); + + }); + +} diff --git a/coinlib/test/tx/transaction_test.dart b/coinlib/test/tx/transaction_test.dart index e6dd5df..bde9d37 100644 --- a/coinlib/test/tx/transaction_test.dart +++ b/coinlib/test/tx/transaction_test.dart @@ -182,6 +182,7 @@ void main() { prevOut: examplePrevOut, publicKey: pubkey, ), + TaprootKeyInput(prevOut: examplePrevOut), RawInput(prevOut: examplePrevOut, scriptSig: Uint8List(0)), ], outputs: [], @@ -205,9 +206,9 @@ void main() { expect(tx.sign(inputN: 1, key: privkey), isA()); // Input out of range - expect(() => tx.sign(inputN: 3, key: privkey), throwsArgumentError); + expect(() => tx.sign(inputN: 4, key: privkey), throwsArgumentError); - // Wrong key + // Wrong key for P2PKH expect( () => tx.sign(inputN: 1, key: wrongkey), throwsA(isA()), @@ -221,7 +222,7 @@ void main() { // Cannot sign raw unmatched input expect( - () => tx.sign(inputN: 2, key: privkey), + () => tx.sign(inputN: 3, key: privkey), throwsA(isA()), ); @@ -244,6 +245,53 @@ void main() { throwsA(isA()), ); + // Taproot tests + final tr = Taproot(internalKey: pubkey); + final tweakedKey = tr.tweakPrivateKey(privkey); + + final val = BigInt.from(10000); + final prevOuts = [ + Output.fromProgram(val, P2WPKH.fromPublicKey(pubkey)), + Output.fromProgram(val, P2PKH.fromPublicKey(pubkey)), + Output.fromProgram(val, P2TR.fromTaproot(tr)), + Output.blank(), + ]; + + // Require prev outs for TR + expect( + () => tx.sign( + inputN: 2, + key: tweakedKey, + ), + throwsA(isA()), + ); + + // Require prev out number to match number of inputs + expect( + () => tx.sign( + inputN: 2, + key: tweakedKey, + prevOuts: prevOuts.sublist(0, 3), + ), + throwsA(isA()), + ); + + // Wrong (untweaked) key for TR + expect( + () => tx.sign( + inputN: 2, + key: privkey, + prevOuts: prevOuts, + ), + throwsA(isA()), + ); + + // Ensure it does work with correct key + expect( + tx.sign(inputN: 2, key: tweakedKey, prevOuts: prevOuts), + isA(), + ); + }); test("immutable inputs/outputs", () {