Skip to content

Commit c13aa22

Browse files
authored
Add support for reading PSBTs with P2A inputs (#167)
Since P2A are spent with an empty witness stack, Bitcoin Core doesn't bother serializing a script witness in the PSBT at all, which led us to treat the input as not finalized. We now immediately finalize P2A inputs included in PSBTs, since they can be spent by anyone without a signature.
1 parent 35bff86 commit c13aa22

File tree

4 files changed

+91
-6
lines changed

4 files changed

+91
-6
lines changed

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ plugins {
1313
val currentOs = org.gradle.internal.os.OperatingSystem.current()
1414

1515
group = "fr.acinq.bitcoin"
16-
version = "0.28.0"
16+
version = "0.28.1"
1717

1818
repositories {
1919
google()

src/commonMain/kotlin/fr/acinq/bitcoin/Script.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import fr.acinq.bitcoin.io.Input
2222
import fr.acinq.bitcoin.io.Output
2323
import fr.acinq.secp256k1.Hex
2424
import fr.acinq.secp256k1.Secp256k1
25+
import kotlin.jvm.JvmField
2526
import kotlin.jvm.JvmStatic
2627

2728
public object Script {
@@ -525,11 +526,11 @@ public object Script {
525526
}
526527

527528
/** Standard P2A (pay-to-anchor) output. */
528-
@JvmStatic
529+
@JvmField
529530
public val pay2anchor: List<ScriptElt> = listOf(OP_1, OP_PUSHDATA(ByteVector("4e73")))
530531

531532
/** An empty witness script is used to spend [pay2anchor] outputs. */
532-
@JvmStatic
533+
@JvmField
533534
public val witnessPay2anchor: ScriptWitness = ScriptWitness.empty
534535

535536
public fun removeSignature(script: List<ScriptElt>, signature: ByteVector): List<ScriptElt> {

src/commonMain/kotlin/fr/acinq/bitcoin/psbt/Psbt.kt

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -350,9 +350,9 @@ public data class Psbt(@JvmField val global: Global, @JvmField val inputs: List<
350350
return when (input) {
351351
is Input.PartiallySignedInputWithoutUtxo -> Either.Left(UpdateFailure.CannotSignInput(inputIndex, "cannot sign: input hasn't been updated with utxo data"))
352352
is Input.WitnessInput.PartiallySignedWitnessInput -> {
353-
if (input.nonWitnessUtxo != null && input.nonWitnessUtxo!!.txid != txIn.outPoint.txid) {
353+
if (input.nonWitnessUtxo != null && input.nonWitnessUtxo.txid != txIn.outPoint.txid) {
354354
Either.Left(UpdateFailure.InvalidNonWitnessUtxo("non-witness utxo does not match unsigned tx input"))
355-
} else if (input.nonWitnessUtxo != null && input.nonWitnessUtxo!!.txOut.size <= txIn.outPoint.index) {
355+
} else if (input.nonWitnessUtxo != null && input.nonWitnessUtxo.txOut.size <= txIn.outPoint.index) {
356356
Either.Left(UpdateFailure.InvalidNonWitnessUtxo("non-witness utxo index out of bounds"))
357357
} else if (!Script.isNativeWitnessScript(input.txOut.publicKeyScript) && !Script.isPayToScript(input.txOut.publicKeyScript.toByteArray())) {
358358
Either.Left(UpdateFailure.InvalidWitnessUtxo("witness utxo must use native segwit or P2SH embedded segwit"))
@@ -556,7 +556,7 @@ public data class Psbt(@JvmField val global: Global, @JvmField val inputs: List<
556556
return try {
557557
Transaction.correctlySpends(finalTx, utxos.toMap(), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
558558
Either.Right(finalTx)
559-
} catch (e: Exception) {
559+
} catch (_: Exception) {
560560
Either.Left(UpdateFailure.CannotExtractTx("extracted transaction doesn't pass standard script validation"))
561561
}
562562
}
@@ -1126,9 +1126,13 @@ public data class Psbt(@JvmField val global: Global, @JvmField val inputs: List<
11261126
taprootInternalKey: XonlyPublicKey?,
11271127
unknown: List<DataEntry>
11281128
): Input {
1129+
val outputIndex = txIn.outPoint.index.toInt()
11291130
val emptied = redeemScript == null && witnessScript == null && partialSigs.isEmpty() && derivationPaths.isEmpty() && sighashType == null
11301131
return when {
11311132
// @formatter:off
1133+
// If the input is P2A, it doesn't need any signature to be finalized, anyone can spend it.
1134+
witnessUtxo != null && witnessUtxo.publicKeyScript == Script.write(Script.pay2anchor).byteVector() -> Input.WitnessInput.FinalizedWitnessInput(witnessUtxo, nonWitnessUtxo, Script.witnessPay2anchor, scriptSig, ripemd160, sha256, hash160, hash256, unknown)
1135+
nonWitnessUtxo != null && nonWitnessUtxo.txOut[outputIndex].publicKeyScript == Script.write(Script.pay2anchor).byteVector() -> Input.WitnessInput.FinalizedWitnessInput(nonWitnessUtxo.txOut[outputIndex], nonWitnessUtxo, Script.witnessPay2anchor, scriptSig, ripemd160, sha256, hash160, hash256, unknown)
11321136
// If the input is finalized, it must have been emptied otherwise it's invalid.
11331137
witnessUtxo != null && scriptWitness != null && emptied -> Input.WitnessInput.FinalizedWitnessInput(witnessUtxo, nonWitnessUtxo, scriptWitness, scriptSig, ripemd160, sha256, hash160, hash256, unknown)
11341138
nonWitnessUtxo != null && scriptSig != null && emptied -> Input.NonWitnessInput.FinalizedNonWitnessInput(nonWitnessUtxo, txIn.outPoint.index.toInt(), scriptSig, ripemd160, sha256, hash160, hash256, unknown)

src/commonTest/kotlin/fr/acinq/bitcoin/psbt/PsbtTestsCommon.kt

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,6 +1102,86 @@ class PsbtTestsCommon {
11021102
assertNotNull(finalTx.right)
11031103
}
11041104

1105+
@Test
1106+
fun `bump lightning commit tx fee from cold wallet with P2A output`() {
1107+
// A lightning node prepares a PSBT that spends the anchor output of a commitment transaction.
1108+
val lightningPsbt = run {
1109+
val txToBump = Transaction(3, listOf(), listOf(TxOut(0.sat(), Script.pay2anchor)), 0)
1110+
val lightningPsbt = Psbt(Transaction(3, listOf(TxIn(OutPoint(txToBump, 0), ByteVector.empty, 0, Script.witnessPay2anchor)), listOf(), 0))
1111+
.updateWitnessInput(OutPoint(txToBump, 0), txToBump.txOut[0], null, Script.pay2anchor, SIGHASH_ALL)
1112+
assertTrue(lightningPsbt.isRight)
1113+
lightningPsbt.right!!
1114+
}
1115+
1116+
// A cold wallet adds inputs and finalizes a transaction that bumps the fees of the commitment transaction.
1117+
val walletPrivKey = PrivateKey(ByteVector32("0202020202020202020202020202020202020202020202020202020202020202"))
1118+
val confirmedTx = Transaction(2, listOf(), listOf(TxOut(100_000.sat(), Script.pay2wpkh(walletPrivKey.publicKey()))), 0)
1119+
val finalTx = Psbt.join(
1120+
lightningPsbt,
1121+
Psbt(Transaction(3, listOf(TxIn(OutPoint(confirmedTx, 0), 0)), listOf(TxOut(75_000.sat(), Script.pay2wpkh(walletPrivKey.publicKey()))), 0))
1122+
).flatMap {
1123+
it.updateWitnessInputTx(confirmedTx, 0, null, Script.pay2pkh(walletPrivKey.publicKey()))
1124+
}.flatMap {
1125+
it.sign(walletPrivKey, 1)
1126+
}.flatMap {
1127+
it.psbt.finalizeWitnessInput(0, Script.witnessPay2anchor)
1128+
}.flatMap {
1129+
it.finalizeWitnessInput(1, Script.witnessPay2wpkh(walletPrivKey.publicKey(), it.inputs[1].partialSigs.getValue(walletPrivKey.publicKey())))
1130+
}.flatMap {
1131+
it.extract()
1132+
}
1133+
assertTrue(finalTx.isRight)
1134+
assertNotNull(finalTx.right)
1135+
}
1136+
1137+
@Test
1138+
fun `read PSBT with P2A input`() {
1139+
val walletPrivKey = PrivateKey(ByteVector32("0202020202020202020202020202020202020202020202020202020202020202"))
1140+
val walletTx = Transaction(2, listOf(TxIn(OutPoint(TxId("75ddabb27b8845f5247975c8a5ba7c6f336c4570708ebe230caf6db5217ae858"), 2), ByteVector.empty, 0)), listOf(TxOut(100_000.sat(), Script.pay2wpkh(walletPrivKey.publicKey()))), 0)
1141+
val txToBump = Transaction(3, listOf(TxIn(OutPoint(TxId("0a6a357e2f7796444e02638749d9611c008b253fb55f5dc88b739b230ed0c4c3"), 1), ByteVector.empty, 0)), listOf(TxOut(0.sat(), Script.pay2anchor), TxOut(50_000.sat(), Script.pay2wpkh(walletPrivKey.publicKey()))), 0)
1142+
val dummyTx = Transaction(
1143+
version = 3,
1144+
txIn = listOf(
1145+
TxIn(OutPoint(txToBump, 0), ByteVector.empty, 0),
1146+
TxIn(OutPoint(walletTx, 0), ByteVector.empty, 0),
1147+
),
1148+
txOut = listOf(
1149+
TxOut(90_000.sat(), Script.pay2wpkh(walletPrivKey.publicKey()))
1150+
),
1151+
lockTime = 0
1152+
)
1153+
val psbt1 = Psbt(dummyTx)
1154+
.updateWitnessInput(OutPoint(walletTx, 0), walletTx.txOut[0], null, Script.pay2pkh(walletPrivKey.publicKey()))
1155+
.flatMap { it.updateWitnessInput(OutPoint(txToBump, 0), txToBump.txOut[0], null, null) }
1156+
.flatMap { it.sign(walletPrivKey, 1) }
1157+
.flatMap { it.psbt.finalizeWitnessInput(1, Script.witnessPay2wpkh(walletPrivKey.publicKey(), it.psbt.inputs[1].partialSigs.getValue(walletPrivKey.publicKey()))) }
1158+
.right
1159+
assertNotNull(psbt1)
1160+
val finalTx1 = psbt1.finalizeWitnessInput(0, Script.witnessPay2anchor).flatMap { it.extract() }.right
1161+
assertNotNull(finalTx1)
1162+
Transaction.correctlySpends(finalTx1, listOf(walletTx, txToBump), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
1163+
1164+
val psbt2 = Psbt(dummyTx)
1165+
.updateWitnessInput(OutPoint(walletTx, 0), walletTx.txOut[0], null, Script.pay2pkh(walletPrivKey.publicKey()))
1166+
.flatMap { it.updateWitnessInputTx(txToBump, 0) }
1167+
.flatMap { it.sign(walletPrivKey, 1) }
1168+
.flatMap { it.psbt.finalizeWitnessInput(1, Script.witnessPay2wpkh(walletPrivKey.publicKey(), it.psbt.inputs[1].partialSigs.getValue(walletPrivKey.publicKey()))) }
1169+
.right
1170+
assertNotNull(psbt2)
1171+
val finalTx2 = psbt2.finalizeWitnessInput(0, Script.witnessPay2anchor).flatMap { it.extract() }.right
1172+
assertNotNull(finalTx2)
1173+
Transaction.correctlySpends(finalTx2, listOf(walletTx, txToBump), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
1174+
1175+
// When serializing PSBTs, Bitcoin Core skips empty witnesses instead of encoding explicitly an empty witness stack.
1176+
// This means that it essentially reverts the finalization of the P2A output that we may have previously done.
1177+
// But this is fine: when reading a P2A input, we should always set its witness to an empty witness, which finalizes it.
1178+
listOf(psbt1, psbt2).forEach { psbt ->
1179+
val finalTx = Psbt.read(Psbt.write(psbt)).right?.extract()?.right
1180+
assertNotNull(finalTx)
1181+
Transaction.correctlySpends(finalTx, listOf(walletTx, txToBump), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
1182+
}
1183+
}
1184+
11051185
@Test
11061186
fun `manual coinjoin workflow`() {
11071187
val alicePrivKey = DeterministicWallet.derivePrivateKey(masterPrivKey, KeyPath("m/0'/0'/1'")).privateKey

0 commit comments

Comments
 (0)