Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions coinlib/lib/src/coinlib_base.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 2 additions & 0 deletions coinlib/lib/src/tx/inputs/input.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down
134 changes: 134 additions & 0 deletions coinlib/lib/src/tx/inputs/p2sh_p2wpkh_input.dart
Original file line number Diff line number Diff line change
@@ -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<Uint8List> 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<Uint8List> 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;
}
40 changes: 36 additions & 4 deletions coinlib/lib/src/tx/transaction.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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([]);
}
}
}

Expand Down Expand Up @@ -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<P2SHP2WPKHInput>(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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
Loading