From 5f5935b0f4e0d51f989bd0f0e725510427485a55 Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 24 Aug 2022 11:42:17 +0200 Subject: [PATCH] Implement interactive-tx construction We implement the protocol described in the dual funding specification (see https://github.com/lightning/bolts/pull/851) to collaboratively create a shared transaction. We don't support yet funding and signing such transactions. --- .../fr/acinq/lightning/channel/Channel.kt | 1 + .../acinq/lightning/channel/InteractiveTx.kt | 278 +++++++++ .../lightning/transactions/Transactions.kt | 3 +- .../acinq/lightning/wire/InteractiveTxTlv.kt | 54 ++ .../acinq/lightning/wire/LightningMessages.kt | 273 ++++++++- .../channel/InteractiveTxTestsCommon.kt | 538 ++++++++++++++++++ .../wire/LightningCodecsTestsCommon.kt | 41 +- 7 files changed, 1184 insertions(+), 4 deletions(-) create mode 100644 src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt create mode 100644 src/commonMain/kotlin/fr/acinq/lightning/wire/InteractiveTxTlv.kt create mode 100644 src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/Channel.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/Channel.kt index 9bd0e7491..c169515d7 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/Channel.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/Channel.kt @@ -552,6 +552,7 @@ sealed class ChannelStateWithCommitments : ChannelState() { return Pair(Aborted(staticParams, currentTip, currentOnChainFeerates), listOf(ChannelAction.Message.Send(error))) } } + object Channel { // see https://github.com/lightningnetwork/lightning-rfc/blob/master/07-routing-gossip.md#requirements const val ANNOUNCEMENTS_MINCONF = 6 diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt new file mode 100644 index 000000000..5cbfe7665 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt @@ -0,0 +1,278 @@ +package fr.acinq.lightning.channel + +import fr.acinq.bitcoin.* +import fr.acinq.bitcoin.Script.tail +import fr.acinq.lightning.Lightning.secureRandom +import fr.acinq.lightning.blockchain.fee.FeeratePerKw +import fr.acinq.lightning.transactions.Transactions +import fr.acinq.lightning.utils.Either +import fr.acinq.lightning.utils.sum +import fr.acinq.lightning.wire.* + +/** + * Created by t-bast on 22/08/2022. + */ + +data class InteractiveTxParams( + val channelId: ByteVector32, + val isInitiator: Boolean, + val localAmount: Satoshi, + val remoteAmount: Satoshi, + val fundingPubkeyScript: ByteVector, + val lockTime: Long, + val dustLimit: Satoshi, + val targetFeerate: FeeratePerKw +) { + val fundingAmount: Satoshi = localAmount + remoteAmount +} + +/** Inputs and outputs we contribute to the funding transaction. */ +data class FundingContributions(val inputs: List, val outputs: List) + +/** A lighter version of our peer's TxAddInput that avoids storing potentially large messages in our DB. */ +data class RemoteTxAddInput(val serialId: Long, val outPoint: OutPoint, val txOut: TxOut, val sequence: Long) { + constructor(i: TxAddInput) : this(i.serialId, OutPoint(i.previousTx, i.previousTxOutput), i.previousTx.txOut[i.previousTxOutput.toInt()], i.sequence) +} + +/** A lighter version of our peer's TxAddOutput that avoids storing potentially large messages in our DB. */ +data class RemoteTxAddOutput(val serialId: Long, val amount: Satoshi, val pubkeyScript: ByteVector) { + constructor(o: TxAddOutput) : this(o.serialId, o.amount, o.pubkeyScript) +} + +/** Unsigned transaction created collaboratively. */ +data class SharedTransaction(val localInputs: List, val remoteInputs: List, val localOutputs: List, val remoteOutputs: List, val lockTime: Long) { + val localAmountIn: Satoshi = localInputs.map { i -> i.previousTx.txOut[i.previousTxOutput.toInt()].amount }.sum() + val remoteAmountIn: Satoshi = remoteInputs.map { i -> i.txOut.amount }.sum() + val totalAmountIn: Satoshi = localAmountIn + remoteAmountIn + val fees: Satoshi = totalAmountIn - localOutputs.map { i -> i.amount }.sum() - remoteOutputs.map { i -> i.amount }.sum() + + fun localFees(params: InteractiveTxParams): Satoshi { + val localAmountOut = params.localAmount + localOutputs.filter { o -> o.pubkeyScript != params.fundingPubkeyScript }.map { o -> o.amount }.sum() + return localAmountIn - localAmountOut + } + + fun buildUnsignedTx(): Transaction { + val localTxIn = localInputs.map { i -> Pair(i.serialId, TxIn(OutPoint(i.previousTx, i.previousTxOutput), ByteVector.empty, i.sequence)) } + val remoteTxIn = remoteInputs.map { i -> Pair(i.serialId, TxIn(i.outPoint, ByteVector.empty, i.sequence)) } + val inputs = (localTxIn + remoteTxIn).sortedBy { (serialId, _) -> serialId }.map { (_, txIn) -> txIn } + val localTxOut = localOutputs.map { o -> Pair(o.serialId, TxOut(o.amount, o.pubkeyScript)) } + val remoteTxOut = remoteOutputs.map { o -> Pair(o.serialId, TxOut(o.amount, o.pubkeyScript)) } + val outputs = (localTxOut + remoteTxOut).sortedBy { (serialId, _) -> serialId }.map { (_, txOut) -> txOut } + return Transaction(2, inputs, outputs, lockTime) + } +} + +/** Signed transaction created collaboratively. */ +sealed class SignedSharedTransaction { + abstract val tx: SharedTransaction + abstract val localSigs: TxSignatures + abstract val signedTx: Transaction? +} + +data class PartiallySignedSharedTransaction(override val tx: SharedTransaction, override val localSigs: TxSignatures) : SignedSharedTransaction() { + override val signedTx = null +} + +data class FullySignedSharedTransaction(override val tx: SharedTransaction, override val localSigs: TxSignatures, val remoteSigs: TxSignatures) : SignedSharedTransaction() { + override val signedTx = run { + require(localSigs.witnesses.size == tx.localInputs.size) { "the number of local signatures does not match the number of local inputs" } + require(remoteSigs.witnesses.size == tx.remoteInputs.size) { "the number of remote signatures does not match the number of remote inputs" } + val signedLocalInputs = tx.localInputs.sortedBy { i -> i.serialId }.zip(localSigs.witnesses).map { (i, w) -> Pair(i.serialId, TxIn(OutPoint(i.previousTx, i.previousTxOutput), ByteVector.empty, i.sequence, w)) } + val signedRemoteInputs = tx.remoteInputs.sortedBy { i -> i.serialId }.zip(remoteSigs.witnesses).map { (i, w) -> Pair(i.serialId, TxIn(i.outPoint, ByteVector.empty, i.sequence, w)) } + val inputs = (signedLocalInputs + signedRemoteInputs).sortedBy { (serialId, _) -> serialId }.map { (_, i) -> i } + val localTxOut = tx.localOutputs.map { o -> Pair(o.serialId, TxOut(o.amount, o.pubkeyScript)) } + val remoteTxOut = tx.remoteOutputs.map { o -> Pair(o.serialId, TxOut(o.amount, o.pubkeyScript)) } + val outputs = (localTxOut + remoteTxOut).sortedBy { (serialId, _) -> serialId }.map { (_, o) -> o } + Transaction(2, inputs, outputs, tx.lockTime) + } + val feerate: FeeratePerKw = Transactions.fee2rate(tx.fees, signedTx.weight()) +} + +sealed class InteractiveTxSessionAction { + data class SendMessage(val msg: InteractiveTxConstructionMessage) : InteractiveTxSessionAction() + data class SignSharedTx(val sharedTx: SharedTransaction, val sharedOutputIndex: Int, val txComplete: TxComplete?) : InteractiveTxSessionAction() + sealed class RemoteFailure : InteractiveTxSessionAction() + data class InvalidSerialId(val channelId: ByteVector32, val serialId: Long) : RemoteFailure() + data class UnknownSerialId(val channelId: ByteVector32, val serialId: Long) : RemoteFailure() + data class TooManyInteractiveTxRounds(val channelId: ByteVector32) : RemoteFailure() + data class DuplicateSerialId(val channelId: ByteVector32, val serialId: Long) : RemoteFailure() + data class DuplicateInput(val channelId: ByteVector32, val serialId: Long, val previousTxId: ByteVector32, val previousTxOutput: Long) : RemoteFailure() + data class InputOutOfBounds(val channelId: ByteVector32, val serialId: Long, val previousTxId: ByteVector32, val previousTxOutput: Long) : RemoteFailure() + data class NonSegwitInput(val channelId: ByteVector32, val serialId: Long, val previousTxId: ByteVector32, val previousTxOutput: Long) : RemoteFailure() + data class NonSegwitOutput(val channelId: ByteVector32, val serialId: Long) : RemoteFailure() + data class OutputBelowDust(val channelId: ByteVector32, val serialId: Long, val amount: Satoshi, val dustLimit: Satoshi) : RemoteFailure() + data class InvalidTxInputOutputCount(val channelId: ByteVector32, val txId: ByteVector32, val inputCount: Int, val outputCount: Int) : RemoteFailure() + data class InvalidTxSharedOutput(val channelId: ByteVector32, val txId: ByteVector32) : RemoteFailure() + data class InvalidTxSharedAmount(val channelId: ByteVector32, val txId: ByteVector32, val amount: Satoshi, val expected: Satoshi) : RemoteFailure() + data class InvalidTxChangeAmount(val channelId: ByteVector32, val txId: ByteVector32) : RemoteFailure() + data class InvalidTxWeight(val channelId: ByteVector32, val txId: ByteVector32) : RemoteFailure() + data class InvalidTxFeerate(val channelId: ByteVector32, val txId: ByteVector32, val targetFeerate: FeeratePerKw, val actualFeerate: FeeratePerKw) : RemoteFailure() + data class InvalidTxDoesNotDoubleSpendPreviousTx(val channelId: ByteVector32, val txId: ByteVector32, val previousTxId: ByteVector32) : RemoteFailure() +} + +data class InteractiveTxSession( + val fundingParams: InteractiveTxParams, + val toSend: List>, + val previousTxs: List = listOf(), + val localInputs: List = listOf(), + val remoteInputs: List = listOf(), + val localOutputs: List = listOf(), + val remoteOutputs: List = listOf(), + val txCompleteSent: Boolean = false, + val txCompleteReceived: Boolean = false, + val inputsReceivedCount: Int = 0, + val outputsReceivedCount: Int = 0, +) { + constructor(fundingParams: InteractiveTxParams, fundingContributions: FundingContributions, previousTxs: List = listOf()) : this( + fundingParams, + fundingContributions.inputs.map { i -> Either.Left(i) } + fundingContributions.outputs.map { o -> Either.Right(o) }, + previousTxs + ) + + val isComplete: Boolean = txCompleteSent && txCompleteReceived + + fun send(): Pair { + return when (val msg = toSend.firstOrNull()) { + null -> { + val txComplete = TxComplete(fundingParams.channelId) + val next = copy(txCompleteSent = true) + if (next.isComplete) { + Pair(next, next.validateTx(txComplete)) + } else { + Pair(next, InteractiveTxSessionAction.SendMessage(txComplete)) + } + } + is Either.Left -> { + val next = copy(toSend = toSend.tail(), localInputs = localInputs + msg.value, txCompleteSent = false) + Pair(next, InteractiveTxSessionAction.SendMessage(msg.value)) + } + is Either.Right -> { + val next = copy(toSend = toSend.tail(), localOutputs = localOutputs + msg.value, txCompleteSent = false) + Pair(next, InteractiveTxSessionAction.SendMessage(msg.value)) + } + } + } + + fun receive(message: InteractiveTxConstructionMessage): Pair { + if (message is HasSerialId && (message.serialId.mod(2) == 1) != fundingParams.isInitiator) { + return Pair(this, InteractiveTxSessionAction.InvalidSerialId(fundingParams.channelId, message.serialId)) + } + return when (message) { + is TxAddInput -> { + if (inputsReceivedCount + 1 >= MAX_INPUTS_OUTPUTS_RECEIVED) { + Pair(this, InteractiveTxSessionAction.TooManyInteractiveTxRounds(message.channelId)) + } else if (remoteInputs.find { i -> i.serialId == message.serialId } != null) { + Pair(this, InteractiveTxSessionAction.DuplicateSerialId(message.channelId, message.serialId)) + } else if (message.previousTx.txOut.size <= message.previousTxOutput) { + Pair(this, InteractiveTxSessionAction.InputOutOfBounds(message.channelId, message.serialId, message.previousTx.txid, message.previousTxOutput)) + } else if ((localInputs.map { i -> OutPoint(i.previousTx, i.previousTxOutput) } + remoteInputs.map { i -> OutPoint(i.previousTx, i.previousTxOutput) }).contains(OutPoint(message.previousTx, message.previousTxOutput))) { + Pair(this, InteractiveTxSessionAction.DuplicateInput(message.channelId, message.serialId, message.previousTx.txid, message.previousTxOutput)) + } else if (!Script.isNativeWitnessScript(message.previousTx.txOut[message.previousTxOutput.toInt()].publicKeyScript)) { + Pair(this, InteractiveTxSessionAction.NonSegwitInput(message.channelId, message.serialId, message.previousTx.txid, message.previousTxOutput)) + } else { + val next = copy(remoteInputs = remoteInputs + message, inputsReceivedCount = inputsReceivedCount + 1, txCompleteReceived = false) + next.send() + } + } + is TxAddOutput -> { + if (outputsReceivedCount + 1 >= MAX_INPUTS_OUTPUTS_RECEIVED) { + Pair(this, InteractiveTxSessionAction.TooManyInteractiveTxRounds(message.channelId)) + } else if (remoteOutputs.find { o -> o.serialId == message.serialId } != null) { + Pair(this, InteractiveTxSessionAction.DuplicateSerialId(message.channelId, message.serialId)) + } else if (message.amount < fundingParams.dustLimit) { + Pair(this, InteractiveTxSessionAction.OutputBelowDust(message.channelId, message.serialId, message.amount, fundingParams.dustLimit)) + } else if (!Script.isNativeWitnessScript(message.pubkeyScript)) { + Pair(this, InteractiveTxSessionAction.NonSegwitOutput(message.channelId, message.serialId)) + } else { + val next = copy(remoteOutputs = remoteOutputs + message, outputsReceivedCount = outputsReceivedCount + 1, txCompleteReceived = false) + next.send() + } + } + is TxRemoveInput -> { + val remoteInputs1 = remoteInputs.filterNot { i -> i.serialId == message.serialId } + if (remoteInputs.size != remoteInputs1.size) { + val next = copy(remoteInputs = remoteInputs1, txCompleteReceived = false) + next.send() + } else { + Pair(this, InteractiveTxSessionAction.UnknownSerialId(message.channelId, message.serialId)) + } + } + is TxRemoveOutput -> { + val remoteOutputs1 = remoteOutputs.filterNot { i -> i.serialId == message.serialId } + if (remoteOutputs.size != remoteOutputs1.size) { + val next = copy(remoteOutputs = remoteOutputs1, txCompleteReceived = false) + next.send() + } else { + Pair(this, InteractiveTxSessionAction.UnknownSerialId(message.channelId, message.serialId)) + } + } + is TxComplete -> { + val next = copy(txCompleteReceived = true) + if (next.isComplete) { + Pair(next, next.validateTx(null)) + } else { + next.send() + } + } + } + } + + private fun validateTx(txComplete: TxComplete?): InteractiveTxSessionAction { + val sharedTx = SharedTransaction(localInputs, remoteInputs.map { i -> RemoteTxAddInput(i) }, localOutputs, remoteOutputs.map { o -> RemoteTxAddOutput(o) }, fundingParams.lockTime) + val tx = sharedTx.buildUnsignedTx() + + if (tx.txIn.size > 252 || tx.txOut.size > 252) { + return InteractiveTxSessionAction.InvalidTxInputOutputCount(fundingParams.channelId, tx.txid, tx.txIn.size, tx.txOut.size) + } + + val sharedOutputs = tx.txOut.withIndex().filter { txOut -> txOut.value.publicKeyScript == fundingParams.fundingPubkeyScript } + if (sharedOutputs.size != 1) { + return InteractiveTxSessionAction.InvalidTxSharedOutput(fundingParams.channelId, tx.txid) + } + val (sharedOutputIndex, sharedOutput) = sharedOutputs.first() + if (sharedOutput.amount != fundingParams.fundingAmount) { + return InteractiveTxSessionAction.InvalidTxSharedAmount(fundingParams.channelId, tx.txid, sharedOutput.amount, fundingParams.fundingAmount) + } + + val localAmountOut = sharedTx.localOutputs.filter { o -> o.pubkeyScript != fundingParams.fundingPubkeyScript }.map { o -> o.amount }.sum() + fundingParams.localAmount + val remoteAmountOut = sharedTx.remoteOutputs.filter { o -> o.pubkeyScript != fundingParams.fundingPubkeyScript }.map { o -> o.amount }.sum() + fundingParams.remoteAmount + if (sharedTx.localAmountIn < localAmountOut || sharedTx.remoteAmountIn < remoteAmountOut) { + return InteractiveTxSessionAction.InvalidTxChangeAmount(fundingParams.channelId, tx.txid) + } + + // The transaction isn't signed yet, so we estimate its weight knowing that all inputs are using native segwit. + val minimumWitnessWeight = 107 // see Bolt 3 + val minimumWeight = tx.weight() + tx.txIn.size * minimumWitnessWeight + if (minimumWeight > Transactions.MAX_STANDARD_TX_WEIGHT) { + return InteractiveTxSessionAction.InvalidTxWeight(fundingParams.channelId, tx.txid) + } + val minimumFee = Transactions.weight2fee(fundingParams.targetFeerate, minimumWeight) + if (sharedTx.fees < minimumFee) { + return InteractiveTxSessionAction.InvalidTxFeerate(fundingParams.channelId, tx.txid, fundingParams.targetFeerate, Transactions.fee2rate(sharedTx.fees, minimumWeight)) + } + + // The transaction must double-spend every previous attempt, otherwise there is a risk that two funding transactions + // confirm for the same channel. + val currentInputs = tx.txIn.map { i -> i.outPoint }.toSet() + previousTxs.forEach { previousSharedTx -> + val previousTx = previousSharedTx.tx.buildUnsignedTx() + val previousInputs = previousTx.txIn.map { i -> i.outPoint } + if (previousInputs.find { i -> currentInputs.contains(i) } == null) { + return InteractiveTxSessionAction.InvalidTxDoesNotDoubleSpendPreviousTx(fundingParams.channelId, tx.txid, previousTx.txid) + } + } + + return InteractiveTxSessionAction.SignSharedTx(sharedTx, sharedOutputIndex, txComplete) + } + + companion object { + // We restrict the number of inputs / outputs that our peer can send us to ensure the protocol eventually ends. + const val MAX_INPUTS_OUTPUTS_RECEIVED = 4096 + + /** The initiator must use even values and the non-initiator odd values. */ + fun generateSerialId(isInitiator: Boolean): Long { + val l = secureRandom.nextLong() + return if (isInitiator) (l / 2) * 2 else (l / 2) * 2 + 1 + } + } +} diff --git a/src/commonMain/kotlin/fr/acinq/lightning/transactions/Transactions.kt b/src/commonMain/kotlin/fr/acinq/lightning/transactions/Transactions.kt index add77d2fe..5006e6d56 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/transactions/Transactions.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/transactions/Transactions.kt @@ -36,9 +36,10 @@ typealias TransactionsCommitmentOutputs = List { + const val tag: Long = 0 + + override fun read(input: Input): SharedOutputContributionTlv = SharedOutputContributionTlv(LightningCodecs.tu64(input).sat) + } + } +} + +sealed class TxAckRbfTlv : Tlv { + /** Amount that the peer will contribute to the transaction's shared output. */ + @Serializable + data class SharedOutputContributionTlv(@Contextual val amount: Satoshi) : TxAckRbfTlv() { + override val tag: Long get() = SharedOutputContributionTlv.tag + + override fun write(out: Output) = LightningCodecs.writeTU64(amount.toLong(), out) + + companion object : TlvValueReader { + const val tag: Long = 0 + + override fun read(input: Input): SharedOutputContributionTlv = SharedOutputContributionTlv(LightningCodecs.tu64(input).sat) + } + } +} + +sealed class TxAbortTlv : Tlv diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt index c19db4d10..bb789ee17 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt @@ -58,6 +58,15 @@ interface LightningMessage { FundingCreated.type -> FundingCreated.read(stream) FundingSigned.type -> FundingSigned.read(stream) FundingLocked.type -> FundingLocked.read(stream) + TxAddInput.type -> TxAddInput.read(stream) + TxAddOutput.type -> TxAddOutput.read(stream) + TxRemoveInput.type -> TxRemoveInput.read(stream) + TxRemoveOutput.type -> TxRemoveOutput.read(stream) + TxComplete.type -> TxComplete.read(stream) + TxSignatures.type -> TxSignatures.read(stream) + TxInitRbf.type -> TxInitRbf.read(stream) + TxAckRbf.type -> TxAckRbf.read(stream) + TxAbort.type -> TxAbort.read(stream) CommitSig.type -> CommitSig.read(stream) RevokeAndAck.type -> RevokeAndAck.read(stream) UpdateAddHtlc.type -> UpdateAddHtlc.read(stream) @@ -123,6 +132,9 @@ interface HtlcSettlementMessage : UpdateMessage { val id: Long } +sealed class InteractiveTxMessage : LightningMessage +sealed class InteractiveTxConstructionMessage : InteractiveTxMessage() + interface HasTemporaryChannelId : LightningMessage { val temporaryChannelId: ByteVector32 } @@ -131,6 +143,10 @@ interface HasChannelId : LightningMessage { val channelId: ByteVector32 } +interface HasSerialId : LightningMessage { + val serialId: Long +} + interface HasChainHash : LightningMessage { val chainHash: ByteVector32 } @@ -288,6 +304,261 @@ data class Pong(val data: ByteVector) : SetupMessage { } } +data class TxAddInput( + override val channelId: ByteVector32, + override val serialId: Long, + val previousTx: Transaction, + val previousTxOutput: Long, + val sequence: Long, + val tlvs: TlvStream = TlvStream.empty() +) : InteractiveTxConstructionMessage(), HasChannelId, HasSerialId { + override val type: Long get() = TxAddInput.type + + override fun write(out: Output) { + LightningCodecs.writeBytes(channelId.toByteArray(), out) + LightningCodecs.writeU64(serialId, out) + val encodedTx = Transaction.write(previousTx) + LightningCodecs.writeU16(encodedTx.size, out) + LightningCodecs.writeBytes(encodedTx, out) + LightningCodecs.writeU32(previousTxOutput.toInt(), out) + LightningCodecs.writeU32(sequence.toInt(), out) + } + + companion object : LightningMessageReader { + const val type: Long = 66 + + override fun read(input: Input): TxAddInput = TxAddInput( + LightningCodecs.bytes(input, 32).byteVector32(), + LightningCodecs.u64(input), + Transaction.read(LightningCodecs.bytes(input, LightningCodecs.u16(input))), + LightningCodecs.u32(input).toLong(), + LightningCodecs.u32(input).toLong(), + ) + } +} + +data class TxAddOutput( + override val channelId: ByteVector32, + override val serialId: Long, + val amount: Satoshi, + val pubkeyScript: ByteVector, + val tlvs: TlvStream = TlvStream.empty() +) : InteractiveTxConstructionMessage(), HasChannelId, HasSerialId { + override val type: Long get() = TxAddOutput.type + + override fun write(out: Output) { + LightningCodecs.writeBytes(channelId.toByteArray(), out) + LightningCodecs.writeU64(serialId, out) + LightningCodecs.writeU64(amount.toLong(), out) + LightningCodecs.writeU16(pubkeyScript.size(), out) + LightningCodecs.writeBytes(pubkeyScript, out) + } + + companion object : LightningMessageReader { + const val type: Long = 67 + + override fun read(input: Input): TxAddOutput = TxAddOutput( + LightningCodecs.bytes(input, 32).byteVector32(), + LightningCodecs.u64(input), + LightningCodecs.u64(input).sat, + LightningCodecs.bytes(input, LightningCodecs.u16(input)).byteVector(), + ) + } +} + +data class TxRemoveInput( + override val channelId: ByteVector32, + override val serialId: Long, + val tlvs: TlvStream = TlvStream.empty() +) : InteractiveTxConstructionMessage(), HasChannelId, HasSerialId { + override val type: Long get() = TxRemoveInput.type + + override fun write(out: Output) { + LightningCodecs.writeBytes(channelId.toByteArray(), out) + LightningCodecs.writeU64(serialId, out) + } + + companion object : LightningMessageReader { + const val type: Long = 68 + + override fun read(input: Input): TxRemoveInput = TxRemoveInput( + LightningCodecs.bytes(input, 32).byteVector32(), + LightningCodecs.u64(input), + ) + } +} + +data class TxRemoveOutput( + override val channelId: ByteVector32, + override val serialId: Long, + val tlvs: TlvStream = TlvStream.empty() +) : InteractiveTxConstructionMessage(), HasChannelId, HasSerialId { + override val type: Long get() = TxRemoveOutput.type + + override fun write(out: Output) { + LightningCodecs.writeBytes(channelId.toByteArray(), out) + LightningCodecs.writeU64(serialId, out) + } + + companion object : LightningMessageReader { + const val type: Long = 69 + + override fun read(input: Input): TxRemoveOutput = TxRemoveOutput( + LightningCodecs.bytes(input, 32).byteVector32(), + LightningCodecs.u64(input), + ) + } +} + +data class TxComplete( + override val channelId: ByteVector32, + val tlvs: TlvStream = TlvStream.empty() +) : InteractiveTxConstructionMessage(), HasChannelId { + override val type: Long get() = TxComplete.type + + override fun write(out: Output) = LightningCodecs.writeBytes(channelId.toByteArray(), out) + + companion object : LightningMessageReader { + const val type: Long = 70 + + override fun read(input: Input): TxComplete = TxComplete(LightningCodecs.bytes(input, 32).byteVector32()) + } +} + +data class TxSignatures( + override val channelId: ByteVector32, + val txId: ByteVector32, + val witnesses: List, + val tlvs: TlvStream = TlvStream.empty() +) : InteractiveTxMessage(), HasChannelId { + override val type: Long get() = TxSignatures.type + + override fun write(out: Output) { + LightningCodecs.writeBytes(channelId.toByteArray(), out) + LightningCodecs.writeBytes(txId.toByteArray(), out) + LightningCodecs.writeU16(witnesses.size, out) + witnesses.forEach { witness -> + LightningCodecs.writeU16(witness.stack.size, out) + witness.stack.forEach { element -> + LightningCodecs.writeU16(element.size(), out) + LightningCodecs.writeBytes(element.toByteArray(), out) + } + } + } + + companion object : LightningMessageReader { + const val type: Long = 71 + + override fun read(input: Input): TxSignatures { + val channelId = LightningCodecs.bytes(input, 32).byteVector32() + val txId = LightningCodecs.bytes(input, 32).byteVector32() + val witnessCount = LightningCodecs.u16(input) + val witnesses = ArrayList(witnessCount) + for (i in 1..witnessCount) { + val stackSize = LightningCodecs.u16(input) + val stack = ArrayList(stackSize) + for (j in 1..stackSize) { + val elementSize = LightningCodecs.u16(input) + stack += LightningCodecs.bytes(input, elementSize).byteVector() + } + witnesses += ScriptWitness(stack.toList()) + } + return TxSignatures(channelId, txId, witnesses) + } + } +} + +data class TxInitRbf( + override val channelId: ByteVector32, + val lockTime: Long, + val feerate: FeeratePerKw, + val tlvs: TlvStream = TlvStream.empty() +) : InteractiveTxMessage(), HasChannelId { + constructor(channelId: ByteVector32, lockTime: Long, feerate: FeeratePerKw, fundingContribution: Satoshi) : this(channelId, lockTime, feerate, TlvStream(listOf(TxInitRbfTlv.SharedOutputContributionTlv(fundingContribution)))) + + @Transient + val fundingContribution = tlvs.get()?.amount ?: 0.sat + + override val type: Long get() = TxInitRbf.type + + override fun write(out: Output) { + LightningCodecs.writeBytes(channelId.toByteArray(), out) + LightningCodecs.writeU32(lockTime.toInt(), out) + LightningCodecs.writeU32(feerate.toLong().toInt(), out) + TlvStreamSerializer(false, readers).write(tlvs, out) + } + + companion object : LightningMessageReader { + const val type: Long = 72 + + @Suppress("UNCHECKED_CAST") + val readers = mapOf(TxInitRbfTlv.SharedOutputContributionTlv.tag to TxInitRbfTlv.SharedOutputContributionTlv.Companion as TlvValueReader) + + override fun read(input: Input): TxInitRbf = TxInitRbf( + LightningCodecs.bytes(input, 32).byteVector32(), + LightningCodecs.u32(input).toLong(), + FeeratePerKw(LightningCodecs.u32(input).toLong().sat), + TlvStreamSerializer(false, readers).read(input), + ) + } +} + +data class TxAckRbf( + override val channelId: ByteVector32, + val tlvs: TlvStream = TlvStream.empty() +) : InteractiveTxMessage(), HasChannelId { + constructor(channelId: ByteVector32, fundingContribution: Satoshi) : this(channelId, TlvStream(listOf(TxAckRbfTlv.SharedOutputContributionTlv(fundingContribution)))) + + @Transient + val fundingContribution = tlvs.get()?.amount ?: 0.sat + + override val type: Long get() = TxAckRbf.type + + override fun write(out: Output) { + LightningCodecs.writeBytes(channelId.toByteArray(), out) + TlvStreamSerializer(false, readers).write(tlvs, out) + } + + companion object : LightningMessageReader { + const val type: Long = 73 + + @Suppress("UNCHECKED_CAST") + val readers = mapOf(TxAckRbfTlv.SharedOutputContributionTlv.tag to TxAckRbfTlv.SharedOutputContributionTlv.Companion as TlvValueReader) + + override fun read(input: Input): TxAckRbf = TxAckRbf( + LightningCodecs.bytes(input, 32).byteVector32(), + TlvStreamSerializer(false, readers).read(input), + ) + } +} + +data class TxAbort( + override val channelId: ByteVector32, + val data: ByteVector, + val tlvs: TlvStream = TlvStream.empty() +) : InteractiveTxMessage(), HasChannelId { + constructor(channelId: ByteVector32, message: String?) : this(channelId, ByteVector(message?.encodeToByteArray() ?: ByteArray(0))) + + fun toAscii(): String = data.toByteArray().decodeToString() + + override val type: Long get() = TxAbort.type + + override fun write(out: Output) { + LightningCodecs.writeBytes(channelId, out) + LightningCodecs.writeU16(data.size(), out) + LightningCodecs.writeBytes(data, out) + } + + companion object : LightningMessageReader { + const val type: Long = 74 + + override fun read(input: Input): TxAbort = TxAbort( + LightningCodecs.bytes(input, 32).byteVector32(), + LightningCodecs.bytes(input, LightningCodecs.u16(input)).byteVector(), + ) + } +} + @Serializable data class OpenChannel( @Contextual override val chainHash: ByteVector32, @@ -1353,7 +1624,6 @@ data class PhoenixAndroidLegacyInfo( } } -@OptIn(ExperimentalUnsignedTypes::class) data class PhoenixAndroidLegacyMigrate( val newNodeId: PublicKey ) : LightningMessage { @@ -1372,7 +1642,6 @@ data class PhoenixAndroidLegacyMigrate( } } -@OptIn(ExperimentalUnsignedTypes::class) @Serializable data class OnionMessage( @Contextual val blindingKey: PublicKey, diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt new file mode 100644 index 000000000..e245de56f --- /dev/null +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt @@ -0,0 +1,538 @@ +package fr.acinq.lightning.channel + +import fr.acinq.bitcoin.* +import fr.acinq.lightning.Lightning.randomBytes32 +import fr.acinq.lightning.Lightning.randomKey +import fr.acinq.lightning.blockchain.fee.FeeratePerKw +import fr.acinq.lightning.channel.InteractiveTxSession.Companion.generateSerialId +import fr.acinq.lightning.tests.utils.LightningTestSuite +import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.wire.* +import kotlin.test.* + +class InteractiveTxTestsCommon : LightningTestSuite() { + + @Test + fun `initiator contributes more than non-initiator`() { + val targetFeerate = FeeratePerKw(5000.sat) + val fundingA = 120_000.sat + val utxosA = listOf(50_000.sat, 35_000.sat, 60_000.sat) + val changeA = listOf(15_000.sat) + val fundingB = 40_000.sat + val utxosB = listOf(100_000.sat) + val changeB = listOf(55_000.sat) + val f = createFixture(fundingA, utxosA, changeA, fundingB, utxosB, changeB, targetFeerate, 660.sat, 42) + assertEquals(f.fundingParamsA.fundingPubkeyScript, f.fundingParamsB.fundingPubkeyScript) + assertEquals(f.fundingParamsA.fundingAmount, 160_000.sat) + assertEquals(f.fundingParamsA.fundingAmount, f.fundingParamsB.fundingAmount) + + val alice0 = InteractiveTxSession(f.fundingParamsA, f.fundingContributionsA) + val bob0 = InteractiveTxSession(f.fundingParamsB, f.fundingContributionsB) + // Alice --- tx_add_input --> Bob + val (alice1, inputA1) = sendMessage(alice0) + // Alice <-- tx_add_input --- Bob + val (bob1, inputB1) = receiveMessage(bob0, inputA1) + // Alice --- tx_add_input --> Bob + val (alice2, inputA2) = receiveMessage(alice1, inputB1) + // Alice <-- tx_add_output --- Bob + val (bob2, outputB1) = receiveMessage(bob1, inputA2) + // Alice --- tx_add_input --> Bob + val (alice3, inputA3) = receiveMessage(alice2, outputB1) + // Alice <-- tx_complete --- Bob + val (bob3, txCompleteB) = receiveMessage(bob2, inputA3) + // Alice --- tx_add_output --> Bob + val (alice4, outputA1) = receiveMessage(alice3, txCompleteB) + // Alice <-- tx_complete --- Bob + val (bob4, _) = receiveMessage(bob3, outputA1) + // Alice --- tx_add_output --> Bob + val (alice5, outputA2) = receiveMessage(alice4, txCompleteB) + assertFalse(alice5.isComplete) + // Alice <-- tx_complete --- Bob + val (bob5, _) = receiveMessage(bob4, outputA2) + assertFalse(bob5.isComplete) + // Alice --- tx_complete --> Bob + val sharedTxA = receiveFinalMessage(alice5, txCompleteB) + assertNotNull(sharedTxA.txComplete) + val sharedTxB = receiveFinalMessage(bob5, sharedTxA.txComplete!!) + assertNull(sharedTxB.txComplete) + + // Alice is responsible for adding the shared output. + assertEquals(outputA1.pubkeyScript, f.fundingParamsA.fundingPubkeyScript) + assertEquals(outputA1.amount, f.fundingParamsA.fundingAmount) + assertNotEquals(outputA1.pubkeyScript, outputA2.pubkeyScript) + + assertEquals(sharedTxA.sharedOutputIndex, sharedTxB.sharedOutputIndex) + assertEquals(sharedTxA.sharedTx.totalAmountIn, 245_000.sat) + assertEquals(sharedTxA.sharedTx.fees, 15_000.sat) + assertEquals(sharedTxA.sharedTx.lockTime, 42) + assertEquals(sharedTxA.sharedTx.localFees(f.fundingParamsA), 10_000.sat) + assertEquals(sharedTxB.sharedTx.localFees(f.fundingParamsB), 5_000.sat) + } + + @Test + fun `initiator contributes less than non-initiator`() { + val targetFeerate = FeeratePerKw(3000.sat) + val fundingA = 10_000.sat + val utxosA = listOf(50_000.sat) + val changeA = listOf(35_000.sat) + val fundingB = 50_000.sat + val utxosB = listOf(80_000.sat) + val changeB = listOf(28_000.sat) + val f = createFixture(fundingA, utxosA, changeA, fundingB, utxosB, changeB, targetFeerate, 660.sat, 0) + assertEquals(f.fundingParamsA.fundingAmount, 60_000.sat) + + val alice0 = InteractiveTxSession(f.fundingParamsA, f.fundingContributionsA) + val bob0 = InteractiveTxSession(f.fundingParamsB, f.fundingContributionsB) + // Even though the initiator isn't contributing, they're paying the fees for the common parts of the transaction. + // Alice --- tx_add_input --> Bob + val (alice1, inputA) = sendMessage(alice0) + // Alice <-- tx_add_input --- Bob + val (bob1, inputB) = receiveMessage(bob0, inputA) + // Alice --- tx_add_output --> Bob + val (alice2, outputA1) = receiveMessage(alice1, inputB) + // Alice <-- tx_add_output --- Bob + val (bob2, outputB) = receiveMessage(bob1, outputA1) + // Alice --- tx_add_output --> Bob + val (alice3, outputA2) = receiveMessage(alice2, outputB) + // Alice <-- tx_complete --- Bob + val (bob3, txCompleteB) = receiveMessage(bob2, outputA2) + // Alice --- tx_complete --> Bob + val sharedTxA = receiveFinalMessage(alice3, txCompleteB) + assertNotNull(sharedTxA.txComplete) + val sharedTxB = receiveFinalMessage(bob3, sharedTxA.txComplete!!) + assertNull(sharedTxB.txComplete) + + // Alice is responsible for adding the shared output. + assertEquals(outputA1.pubkeyScript, f.fundingParamsA.fundingPubkeyScript) + assertEquals(outputA1.amount, f.fundingParamsA.fundingAmount) + assertNotEquals(outputA1.pubkeyScript, outputA2.pubkeyScript) + + assertEquals(sharedTxA.sharedOutputIndex, sharedTxB.sharedOutputIndex) + assertEquals(sharedTxA.sharedTx.totalAmountIn, 130_000.sat) + assertEquals(sharedTxA.sharedTx.fees, 7_000.sat) + assertEquals(sharedTxA.sharedTx.lockTime, 0) + assertEquals(sharedTxA.sharedTx.localFees(f.fundingParamsA), 5_000.sat) + assertEquals(sharedTxB.sharedTx.localFees(f.fundingParamsB), 2_000.sat) + } + + @Test + fun `non-initiator does not contribute`() { + val targetFeerate = FeeratePerKw(2500.sat) + val fundingA = 150_000.sat + val utxosA = listOf(80_000.sat, 120_000.sat) + val changeA = listOf(45_000.sat) + val f = createFixture(fundingA, utxosA, changeA, 0.sat, listOf(), listOf(), targetFeerate, 330.sat, 0) + assertEquals(f.fundingParamsA.fundingAmount, 150_000.sat) + + val alice0 = InteractiveTxSession(f.fundingParamsA, f.fundingContributionsA) + val bob0 = InteractiveTxSession(f.fundingParamsB, f.fundingContributionsB) + // Alice --- tx_add_input --> Bob + val (alice1, inputA1) = sendMessage(alice0) + // Alice <-- tx_complete --- Bob + val (bob1, txCompleteB) = receiveMessage(bob0, inputA1) + // Alice --- tx_add_input --> Bob + val (alice2, inputA2) = receiveMessage(alice1, txCompleteB) + // Alice <-- tx_complete --- Bob + val (bob2, _) = receiveMessage(bob1, inputA2) + // Alice --- tx_add_output --> Bob + val (alice3, outputA1) = receiveMessage(alice2, txCompleteB) + // Alice <-- tx_complete --- Bob + val (bob3, _) = receiveMessage(bob2, outputA1) + // Alice --- tx_add_output --> Bob + val (alice4, outputA2) = receiveMessage(alice3, txCompleteB) + // Alice <-- tx_complete --- Bob + val (bob4, _) = receiveMessage(bob3, outputA2) + // Alice --- tx_complete --> Bob + val sharedTxA = receiveFinalMessage(alice4, txCompleteB) + assertNotNull(sharedTxA.txComplete) + val sharedTxB = receiveFinalMessage(bob4, sharedTxA.txComplete!!) + assertNull(sharedTxB.txComplete) + + // Alice is responsible for adding the shared output. + assertEquals(outputA1.pubkeyScript, f.fundingParamsA.fundingPubkeyScript) + assertEquals(outputA1.amount, f.fundingParamsA.fundingAmount) + assertNotEquals(outputA1.pubkeyScript, outputA2.pubkeyScript) + + assertEquals(sharedTxA.sharedOutputIndex, sharedTxB.sharedOutputIndex) + assertEquals(sharedTxA.sharedTx.totalAmountIn, 200_000.sat) + assertEquals(sharedTxA.sharedTx.fees, 5_000.sat) + assertEquals(sharedTxA.sharedTx.lockTime, 0) + assertEquals(sharedTxA.sharedTx.localFees(f.fundingParamsA), 5_000.sat) + assertEquals(sharedTxB.sharedTx.localFees(f.fundingParamsB), 0.sat) + } + + @Test + fun `remove input - output`() { + val f = createFixture(100_000.sat, listOf(150_000.sat), listOf(40_000.sat), 0.sat, listOf(), listOf(), FeeratePerKw(2500.sat), 330.sat, 0) + assertEquals(f.fundingParamsA.fundingAmount, 100_000.sat) + + // In this flow we introduce dummy inputs/outputs from Bob to Alice that are then removed. + val alice0 = InteractiveTxSession(f.fundingParamsA, f.fundingContributionsA) + // Alice --- tx_add_input --> Bob + val (alice1, inputA) = sendMessage(alice0) + // Alice <-- tx_add_input --- Bob + val inputB = TxAddInput(f.channelId, 1, Transaction(2, listOf(), listOf(TxOut(250_000.sat, Script.pay2wpkh(randomKey().publicKey()))), 0), 0, 0) + // Alice --- tx_add_output --> Bob + val (alice2, _) = receiveMessage(alice1, inputB) + // Alice <-- tx_add_output --- Bob + val outputB = TxAddOutput(f.channelId, 3, 250_000.sat, Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector()) + // Alice --- tx_add_output --> Bob + val (alice3, _) = receiveMessage(alice2, outputB) + // Alice <-- tx_remove_input --- Bob + val removeInputB = TxRemoveInput(f.channelId, 1) + // Alice --- tx_complete --> Bob + val (alice4, _) = receiveMessage(alice3, removeInputB) + // Alice <-- tx_remove_output --- Bob + val remoteOutputB = TxRemoveOutput(f.channelId, 3) + // Alice --- tx_complete --> Bob + val (alice5, _) = receiveMessage(alice4, remoteOutputB) + // Alice <-- tx_complete --- Bob + val sharedTxA = receiveFinalMessage(alice5, TxComplete(f.channelId)) + assertNull(sharedTxA.txComplete) + assertEquals(sharedTxA.sharedTx.remoteAmountIn, 0.sat) + + // The resulting transaction doesn't contain Bob's removed inputs and outputs. + val tx = sharedTxA.sharedTx.buildUnsignedTx() + assertEquals(tx.lockTime, 0) + assertEquals(tx.txIn.size, 1) + assertEquals(tx.txIn.first().outPoint, OutPoint(inputA.previousTx, inputA.previousTxOutput)) + assertEquals(tx.txOut.size, 2) + } + + @Test + fun `invalid input`() { + // Create a transaction with a mix of segwit and non-segwit inputs. + val previousOutputs = listOf( + TxOut(2500.sat, Script.pay2wpkh(randomKey().publicKey())), + TxOut(2500.sat, Script.pay2pkh(randomKey().publicKey())), + ) + val previousTx = Transaction(2, listOf(), previousOutputs, 0) + val f = createFixture(100_000.sat, listOf(120_000.sat), listOf(15_000.sat), 0.sat, listOf(), listOf(), FeeratePerKw(5000.sat), 330.sat, 0) + val testCases = mapOf( + TxAddInput(f.channelId, 0, previousTx, 0, 0) to InteractiveTxSessionAction.InvalidSerialId(f.channelId, 0), + TxAddInput(f.channelId, 1, previousTx, 0, 0) to InteractiveTxSessionAction.DuplicateSerialId(f.channelId, 1), + TxAddInput(f.channelId, 3, previousTx, 0, 0) to InteractiveTxSessionAction.DuplicateInput(f.channelId, 3, previousTx.txid, 0), + TxAddInput(f.channelId, 5, previousTx, 2, 0) to InteractiveTxSessionAction.InputOutOfBounds(f.channelId, 5, previousTx.txid, 2), + TxAddInput(f.channelId, 7, previousTx, 1, 0) to InteractiveTxSessionAction.NonSegwitInput(f.channelId, 7, previousTx.txid, 1), + ) + testCases.forEach { (input, expected) -> + val alice0 = InteractiveTxSession(f.fundingParamsA, f.fundingContributionsA) + // Alice --- tx_add_input --> Bob + val (alice1, _) = sendMessage(alice0) + // Alice <-- tx_add_input --- Bob + val (alice2, _) = receiveMessage(alice1, TxAddInput(f.channelId, 1, previousTx, 0, 0)) + // Alice <-- tx_add_input --- Bob + val failure = receiveInvalidMessage(alice2, input) + assertEquals(failure, expected) + } + } + + @Test + fun `invalid output`() { + val f = createFixture(100_000.sat, listOf(120_000.sat), listOf(15_000.sat), 0.sat, listOf(), listOf(), FeeratePerKw(5000.sat), 330.sat, 0) + val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector() + val testCases = mapOf( + TxAddOutput(f.channelId, 0, 25_000.sat, validScript) to InteractiveTxSessionAction.InvalidSerialId(f.channelId, 0), + TxAddOutput(f.channelId, 1, 45_000.sat, validScript) to InteractiveTxSessionAction.DuplicateSerialId(f.channelId, 1), + TxAddOutput(f.channelId, 3, 329.sat, validScript) to InteractiveTxSessionAction.OutputBelowDust(f.channelId, 3, 329.sat, 330.sat), + TxAddOutput(f.channelId, 5, 45_000.sat, Script.write(Script.pay2pkh(randomKey().publicKey())).byteVector()) to InteractiveTxSessionAction.NonSegwitOutput(f.channelId, 5), + ) + testCases.forEach { (output, expected) -> + val alice0 = InteractiveTxSession(f.fundingParamsA, f.fundingContributionsA) + // Alice --- tx_add_input --> Bob + val (alice1, _) = sendMessage(alice0) + // Alice <-- tx_add_output --- Bob + val (alice2, _) = receiveMessage(alice1, TxAddOutput(f.channelId, 1, 50_000.sat, validScript)) + // Alice <-- tx_add_output --- Bob + val failure = receiveInvalidMessage(alice2, output) + assertEquals(failure, expected) + } + } + + @Test + fun `remove unknown input - output`() { + val f = createFixture(100_000.sat, listOf(120_000.sat), listOf(15_000.sat), 0.sat, listOf(), listOf(), FeeratePerKw(5000.sat), 330.sat, 0) + val testCases = mapOf( + TxRemoveOutput(f.channelId, 52) to InteractiveTxSessionAction.InvalidSerialId(f.channelId, 52), + TxRemoveOutput(f.channelId, 53) to InteractiveTxSessionAction.UnknownSerialId(f.channelId, 53), + TxRemoveInput(f.channelId, 56) to InteractiveTxSessionAction.InvalidSerialId(f.channelId, 56), + TxRemoveInput(f.channelId, 57) to InteractiveTxSessionAction.UnknownSerialId(f.channelId, 57), + ) + testCases.forEach { (msg, expected) -> + val alice0 = InteractiveTxSession(f.fundingParamsA, f.fundingContributionsA) + // Alice --- tx_add_input --> Bob + val (alice1, _) = sendMessage(alice0) + // Alice <-- tx_remove_(in|out)put --- Bob + val failure = receiveInvalidMessage(alice1, msg) + assertEquals(failure, expected) + } + } + + @Test + fun `too many protocol rounds`() { + val f = createFixture(100_000.sat, listOf(120_000.sat), listOf(15_000.sat), 0.sat, listOf(), listOf(), FeeratePerKw(5000.sat), 330.sat, 0) + val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector() + var (alice, _) = InteractiveTxSession(f.fundingParamsA, f.fundingContributionsA).send() + (1..InteractiveTxSession.MAX_INPUTS_OUTPUTS_RECEIVED).forEach { i -> + // Alice --- tx_message --> Bob + val (alice1, _) = alice.receive(TxAddOutput(f.channelId, 2 * i.toLong() + 1, 2500.sat, validScript)) + alice = alice1 + } + val failure = receiveInvalidMessage(alice, TxAddOutput(f.channelId, 15001, 2500.sat, validScript)) + assertEquals(failure, InteractiveTxSessionAction.TooManyInteractiveTxRounds(f.channelId)) + } + + @Test + fun `too many inputs`() { + val f = createFixture(100_000.sat, listOf(120_000.sat), listOf(15_000.sat), 0.sat, listOf(), listOf(), FeeratePerKw(5000.sat), 330.sat, 0) + var (alice, _) = InteractiveTxSession(f.fundingParamsA, f.fundingContributionsA).send() + (1..252).forEach { _ -> + // Alice --- tx_message --> Bob + val (alice1, _) = alice.receive(createTxAddInput(f.channelId, 5000.sat, false)) + alice = alice1 + } + // Alice <-- tx_complete --- Bob + val failure = receiveInvalidMessage(alice, TxComplete(f.channelId)) + assertIs(failure) + assertEquals(failure.inputCount, 253) + assertEquals(failure.outputCount, 2) + } + + @Test + fun `too many outputs`() { + val f = createFixture(100_000.sat, listOf(120_000.sat), listOf(15_000.sat), 0.sat, listOf(), listOf(), FeeratePerKw(5000.sat), 330.sat, 0) + var (alice, _) = InteractiveTxSession(f.fundingParamsA, f.fundingContributionsA).send() + val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector() + (1..252).forEach { i -> + // Alice --- tx_message --> Bob + // Alice --- tx_message --> Bob + val (alice1, _) = alice.receive(TxAddOutput(f.channelId, 2 * i.toLong() + 1, 2500.sat, validScript)) + alice = alice1 + } + // Alice <-- tx_complete --- Bob + val failure = receiveInvalidMessage(alice, TxComplete(f.channelId)) + assertIs(failure) + assertEquals(failure.inputCount, 1) + assertEquals(failure.outputCount, 254) + } + + @Test + fun `missing funding output`() { + val f = createFixture(100_000.sat, listOf(120_000.sat), listOf(15_000.sat), 0.sat, listOf(), listOf(), FeeratePerKw(5000.sat), 330.sat, 0) + val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector() + val bob0 = InteractiveTxSession(f.fundingParamsB, f.fundingContributionsB) + // Alice --- tx_add_input --> Bob + val (bob1, _) = receiveMessage(bob0, createTxAddInput(f.channelId, 150_000.sat, true)) + // Alice --- tx_add_output --> Bob + val (bob2, _) = receiveMessage(bob1, TxAddOutput(f.channelId, 2, 125_000.sat, validScript)) + // Alice --- tx_complete --> Bob + val failure = receiveInvalidMessage(bob2, TxComplete(f.channelId)) + assertIs(failure) + } + + @Test + fun `multiple funding outputs`() { + val f = createFixture(100_000.sat, listOf(120_000.sat), listOf(15_000.sat), 0.sat, listOf(), listOf(), FeeratePerKw(5000.sat), 330.sat, 0) + val bob0 = InteractiveTxSession(f.fundingParamsB, f.fundingContributionsB) + // Alice --- tx_add_input --> Bob + val (bob1, _) = receiveMessage(bob0, createTxAddInput(f.channelId, 150_000.sat, true)) + // Alice --- tx_add_output --> Bob + val (bob2, _) = receiveMessage(bob1, TxAddOutput(f.channelId, 2, 100_000.sat, f.fundingParamsB.fundingPubkeyScript)) + // Alice --- tx_add_output --> Bob + val (bob3, _) = receiveMessage(bob2, TxAddOutput(f.channelId, 4, 25_000.sat, f.fundingParamsB.fundingPubkeyScript)) + // Alice --- tx_complete --> Bob + val failure = receiveInvalidMessage(bob3, TxComplete(f.channelId)) + assertIs(failure) + } + + @Test + fun `invalid funding amount`() { + val f = createFixture(100_000.sat, listOf(120_000.sat), listOf(15_000.sat), 0.sat, listOf(), listOf(), FeeratePerKw(5000.sat), 330.sat, 0) + val bob0 = InteractiveTxSession(f.fundingParamsB, f.fundingContributionsB) + // Alice --- tx_add_input --> Bob + val (bob1, _) = receiveMessage(bob0, createTxAddInput(f.channelId, 150_000.sat, true)) + // Alice --- tx_add_output --> Bob + val (bob2, _) = receiveMessage(bob1, TxAddOutput(f.channelId, 2, 100_001.sat, f.fundingParamsB.fundingPubkeyScript)) + // Alice --- tx_complete --> Bob + val failure = receiveInvalidMessage(bob2, TxComplete(f.channelId)) + assertIs(failure) + assertEquals(failure.expected, 100_000.sat) + assertEquals(failure.amount, 100_001.sat) + } + + @Test + fun `total input amount too low`() { + val f = createFixture(100_000.sat, listOf(120_000.sat), listOf(15_000.sat), 0.sat, listOf(), listOf(), FeeratePerKw(5000.sat), 330.sat, 0) + val bob0 = InteractiveTxSession(f.fundingParamsB, f.fundingContributionsB) + val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector() + // Alice --- tx_add_input --> Bob + val (bob1, _) = receiveMessage(bob0, createTxAddInput(f.channelId, 150_000.sat, true)) + // Alice --- tx_add_output --> Bob + val (bob2, _) = receiveMessage(bob1, TxAddOutput(f.channelId, 2, 100_000.sat, f.fundingParamsB.fundingPubkeyScript)) + // Alice --- tx_add_output --> Bob + val (bob3, _) = receiveMessage(bob2, TxAddOutput(f.channelId, 4, 51_000.sat, validScript)) + // Alice --- tx_complete --> Bob + val failure = receiveInvalidMessage(bob3, TxComplete(f.channelId)) + assertIs(failure) + } + + @Test + fun `minimum fee not met`() { + val f = createFixture(100_000.sat, listOf(120_000.sat), listOf(15_000.sat), 0.sat, listOf(), listOf(), FeeratePerKw(5000.sat), 330.sat, 0) + val bob0 = InteractiveTxSession(f.fundingParamsB, f.fundingContributionsB) + val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector() + // Alice --- tx_add_input --> Bob + val (bob1, _) = receiveMessage(bob0, createTxAddInput(f.channelId, 150_000.sat, true)) + // Alice --- tx_add_output --> Bob + val (bob2, _) = receiveMessage(bob1, TxAddOutput(f.channelId, 2, 100_000.sat, f.fundingParamsB.fundingPubkeyScript)) + // Alice --- tx_add_output --> Bob + val (bob3, _) = receiveMessage(bob2, TxAddOutput(f.channelId, 4, 49_999.sat, validScript)) + // Alice --- tx_complete --> Bob + val failure = receiveInvalidMessage(bob3, TxComplete(f.channelId)) + assertIs(failure) + assertEquals(failure.targetFeerate, FeeratePerKw(5000.sat)) + } + + @Test + fun `previous attempts not double-spent`() { + val f = createFixture(100_000.sat, listOf(120_000.sat), listOf(15_000.sat), 0.sat, listOf(), listOf(), FeeratePerKw(5000.sat), 330.sat, 0) + val previousTx1 = Transaction(2, listOf(), listOf(TxOut(150_000.sat, Script.pay2wpkh(randomKey().publicKey()))), 0) + val previousTx2 = Transaction(2, listOf(), listOf(TxOut(160_000.sat, Script.pay2wpkh(randomKey().publicKey())), TxOut(175_000.sat, Script.pay2wpkh(randomKey().publicKey()))), 0) + val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector() + val firstAttempt = FullySignedSharedTransaction( + SharedTransaction(listOf(), listOf(RemoteTxAddInput(2, OutPoint(previousTx1, 0), TxOut(125_000.sat, validScript), 0)), listOf(), listOf(), 0), + TxSignatures(f.channelId, randomBytes32(), listOf()), + TxSignatures(f.channelId, randomBytes32(), listOf(Script.witnessPay2wpkh(randomKey().publicKey(), ByteVector64.Zeroes))) + ) + val secondAttempt = PartiallySignedSharedTransaction( + SharedTransaction(listOf(), firstAttempt.tx.remoteInputs + listOf(RemoteTxAddInput(4, OutPoint(previousTx2, 1), TxOut(150_000.sat, validScript), 0)), listOf(), listOf(), 0), + TxSignatures(f.channelId, randomBytes32(), listOf()), + ) + val bob0 = InteractiveTxSession(f.fundingParamsB, f.fundingContributionsB, listOf(firstAttempt, secondAttempt)) + // Alice --- tx_add_input --> Bob + val (bob1, _) = receiveMessage(bob0, TxAddInput(f.channelId, 4, previousTx2, 1, 0)) + // Alice --- tx_add_output --> Bob + val (bob2, _) = receiveMessage(bob1, TxAddOutput(f.channelId, 6, f.fundingParamsB.fundingAmount, f.fundingParamsB.fundingPubkeyScript)) + // Alice --- tx_add_output --> Bob + val (bob3, _) = receiveMessage(bob2, TxAddOutput(f.channelId, 8, 25_000.sat, validScript)) + // Alice --- tx_complete --> Bob + val failure = receiveInvalidMessage(bob3, TxComplete(f.channelId)) + assertIs(failure) + } + + @Test + fun `reference test vector`() { + val channelId = ByteVector32.Zeroes + val parentTx = Transaction.read( + "02000000000101f86fd1d0db3ac5a72df968622f31e6b5e6566a09e29206d7c7a55df90e181de800000000171600141fb9623ffd0d422eacc450fd1e967efc477b83ccffffffff0580b2e60e00000000220020fd89acf65485df89797d9ba7ba7a33624ac4452f00db08107f34257d33e5b94680b2e60e0000000017a9146a235d064786b49e7043e4a042d4cc429f7eb6948780b2e60e00000000160014fbb4db9d85fba5e301f4399e3038928e44e37d3280b2e60e0000000017a9147ecd1b519326bc13b0ec716e469b58ed02b112a087f0006bee0000000017a914f856a70093da3a5b5c4302ade033d4c2171705d387024730440220696f6cee2929f1feb3fd6adf024ca0f9aa2f4920ed6d35fb9ec5b78c8408475302201641afae11242160101c6f9932aeb4fcd1f13a9c6df5d1386def000ea259a35001210381d7d5b1bc0d7600565d827242576d9cb793bfe0754334af82289ee8b65d137600000000" + ) + val initiatorInput = TxAddInput(channelId, 20, parentTx, 0, 4294967293L) + val initiatorOutput = TxAddOutput(channelId, 30, 49999845.sat, ByteVector("00141ca1cca8855bad6bc1ea5436edd8cff10b7e448b")) + val sharedOutput = TxAddOutput(channelId, 44, 400000000.sat, ByteVector("0020297b92c238163e820b82486084634b4846b86a3c658d87b9384192e6bea98ec5")) + val nonInitiatorInput = TxAddInput(channelId, 11, parentTx, 2, 4294967293L) + val nonInitiatorOutput = TxAddOutput(channelId, 33, 49999900.sat, ByteVector("001444cb0c39f93ecc372b5851725bd29d865d333b10")) + + val initiatorParams = InteractiveTxParams(channelId, isInitiator = true, 200_000_000.sat, 200_000_000.sat, ByteVector("0020297b92c238163e820b82486084634b4846b86a3c658d87b9384192e6bea98ec5"), 120, 330.sat, FeeratePerKw(253.sat)) + val initiatorTx = SharedTransaction(listOf(initiatorInput), listOf(nonInitiatorInput).map { RemoteTxAddInput(it) }, listOf(initiatorOutput, sharedOutput), listOf(nonInitiatorOutput).map { RemoteTxAddOutput(it) }, 120) + assertEquals(initiatorTx.localFees(initiatorParams), 155.sat) + val nonInitiatorParams = initiatorParams.copy(isInitiator = false) + val nonInitiatorTx = SharedTransaction(listOf(nonInitiatorInput), listOf(initiatorInput).map { RemoteTxAddInput(it) }, listOf(nonInitiatorOutput), listOf(initiatorOutput, sharedOutput).map { RemoteTxAddOutput(it) }, 120) + assertEquals(nonInitiatorTx.localFees(nonInitiatorParams), 100.sat) + + val unsignedTx = Transaction.read( + "0200000002b932b0669cd0394d0d5bcc27e01ab8c511f1662a6799925b346c0cf18fca03430200000000fdffffffb932b0669cd0394d0d5bcc27e01ab8c511f1662a6799925b346c0cf18fca03430000000000fdffffff03e5effa02000000001600141ca1cca8855bad6bc1ea5436edd8cff10b7e448b1cf0fa020000000016001444cb0c39f93ecc372b5851725bd29d865d333b100084d71700000000220020297b92c238163e820b82486084634b4846b86a3c658d87b9384192e6bea98ec578000000" + ) + assertEquals(initiatorTx.buildUnsignedTx().txid, unsignedTx.txid) + assertEquals(nonInitiatorTx.buildUnsignedTx().txid, unsignedTx.txid) + + val initiatorWitness = ScriptWitness( + listOf( + ByteVector("68656c6c6f2074686572652c2074686973206973206120626974636f6e212121"), + ByteVector("82012088a820add57dfe5277079d069ca4ad4893c96de91f88ffb981fdc6a2a34d5336c66aff87") + ) + ) + val initiatorSigs = TxSignatures(channelId, unsignedTx.txid, listOf(initiatorWitness)) + val nonInitiatorWitness = ScriptWitness( + listOf( + ByteVector("304402207de9ba56bb9f641372e805782575ee840a899e61021c8b1572b3ec1d5b5950e9022069e9ba998915dae193d3c25cb89b5e64370e6a3a7755e7f31cf6d7cbc2a49f6d01"), + ByteVector("034695f5b7864c580bf11f9f8cb1a94eb336f2ce9ef872d2ae1a90ee276c772484") + ) + ) + val nonInitiatorSigs = TxSignatures(channelId, unsignedTx.txid, listOf(nonInitiatorWitness)) + val initiatorSignedTx = FullySignedSharedTransaction(initiatorTx, initiatorSigs, nonInitiatorSigs) + assertEquals(initiatorSignedTx.feerate, FeeratePerKw(262.sat)) + val nonInitiatorSignedTx = FullySignedSharedTransaction(nonInitiatorTx, nonInitiatorSigs, initiatorSigs) + assertEquals(nonInitiatorSignedTx.feerate, FeeratePerKw(262.sat)) + val signedTx = Transaction.read( + "02000000000102b932b0669cd0394d0d5bcc27e01ab8c511f1662a6799925b346c0cf18fca03430200000000fdffffffb932b0669cd0394d0d5bcc27e01ab8c511f1662a6799925b346c0cf18fca03430000000000fdffffff03e5effa02000000001600141ca1cca8855bad6bc1ea5436edd8cff10b7e448b1cf0fa020000000016001444cb0c39f93ecc372b5851725bd29d865d333b100084d71700000000220020297b92c238163e820b82486084634b4846b86a3c658d87b9384192e6bea98ec50247304402207de9ba56bb9f641372e805782575ee840a899e61021c8b1572b3ec1d5b5950e9022069e9ba998915dae193d3c25cb89b5e64370e6a3a7755e7f31cf6d7cbc2a49f6d0121034695f5b7864c580bf11f9f8cb1a94eb336f2ce9ef872d2ae1a90ee276c772484022068656c6c6f2074686572652c2074686973206973206120626974636f6e2121212782012088a820add57dfe5277079d069ca4ad4893c96de91f88ffb981fdc6a2a34d5336c66aff8778000000" + ) + assertEquals(initiatorSignedTx.signedTx, signedTx) + assertEquals(initiatorSignedTx.signedTx, nonInitiatorSignedTx.signedTx) + } + + companion object { + data class Fixture(val channelId: ByteVector32, val fundingParamsA: InteractiveTxParams, val fundingContributionsA: FundingContributions, val fundingParamsB: InteractiveTxParams, val fundingContributionsB: FundingContributions) + + private fun createFixture( + fundingAmountA: Satoshi, + utxosA: List, + changeOutputsA: List, + fundingAmountB: Satoshi, + utxosB: List, + changeOutputsB: List, + targetFeerate: FeeratePerKw, + dustLimit: Satoshi, + lockTime: Long + ): Fixture { + val channelId = randomBytes32() + val fundingScript = Script.write(Script.pay2wsh(Script.write(Script.createMultiSigMofN(2, listOf(randomKey().publicKey(), randomKey().publicKey()))))).byteVector() + val contributionsA = FundingContributions( + utxosA.map { amount -> createTxAddInput(channelId, amount, true) }, + listOf(createTxAddOutput(channelId, fundingAmountA + fundingAmountB, true, fundingScript)) + changeOutputsA.map { amount -> createTxAddOutput(channelId, amount, true) }, + ) + val contributionsB = FundingContributions( + utxosB.map { amount -> createTxAddInput(channelId, amount, false) }, + changeOutputsB.map { amount -> createTxAddOutput(channelId, amount, false) }, + ) + val fundingParamsA = InteractiveTxParams(channelId, true, fundingAmountA, fundingAmountB, fundingScript, lockTime, dustLimit, targetFeerate) + val fundingParamsB = InteractiveTxParams(channelId, false, fundingAmountB, fundingAmountA, fundingScript, lockTime, dustLimit, targetFeerate) + return Fixture(channelId, fundingParamsA, contributionsA, fundingParamsB, contributionsB) + } + + private inline fun sendMessage(sender: InteractiveTxSession): Pair { + val (sender1, action1) = sender.send() + assertIs(action1) + assertIs(action1.msg) + return Pair(sender1, action1.msg as M) + } + + private inline fun receiveMessage(receiver: InteractiveTxSession, msg: InteractiveTxConstructionMessage): Pair { + val (receiver1, action1) = receiver.receive(msg) + assertIs(action1) + assertIs(action1.msg) + return Pair(receiver1, action1.msg as M) + } + + private fun receiveFinalMessage(receiver: InteractiveTxSession, msg: TxComplete): InteractiveTxSessionAction.SignSharedTx { + val (receiver1, action1) = receiver.receive(msg) + assertIs(action1) + assertTrue(receiver1.isComplete) + return action1 + } + + private fun receiveInvalidMessage(receiver: InteractiveTxSession, msg: InteractiveTxConstructionMessage): InteractiveTxSessionAction.RemoteFailure { + val (_, action1) = receiver.receive(msg) + assertIs(action1) + return action1 + } + + private fun createTxAddInput(channelId: ByteVector32, amount: Satoshi, isInitiator: Boolean): TxAddInput { + val previousTx = Transaction(2, listOf(), listOf(TxOut(amount, Script.pay2wpkh(randomKey().publicKey()))), 0) + return TxAddInput(channelId, generateSerialId(isInitiator), previousTx, 0, 0) + } + + private fun createTxAddOutput(channelId: ByteVector32, amount: Satoshi, isInitiator: Boolean, pubkeyScript: ByteVector? = null): TxAddOutput { + return TxAddOutput(channelId, generateSerialId(isInitiator), amount, pubkeyScript ?: Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector()) + } + } +} \ No newline at end of file diff --git a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt index 14e1c90e9..2ec4775e9 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt @@ -29,7 +29,6 @@ import kotlin.test.assertEquals import kotlin.test.assertFails import kotlin.test.assertNotNull -@OptIn(ExperimentalUnsignedTypes::class) class LightningCodecsTestsCommon : LightningTestSuite() { private fun point(fill: Byte) = PrivateKey(ByteArray(32) { fill }).publicKey() @@ -354,6 +353,46 @@ class LightningCodecsTestsCommon : LightningTestSuite() { } } + @Test + fun `encode - decode interactive-tx messages`() { + val channelId1 = ByteVector32("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + val channelId2 = ByteVector32("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + // This is a random mainnet transaction. + val tx1 = Transaction.read( + "020000000001014ade359c5deb7c1cde2e94f401854658f97d7fa31c17ce9a831db253120a0a410100000017160014eb9a5bd79194a23d19d6ec473c768fb74f9ed32cffffffff021ca408000000000017a914946118f24bb7b37d5e9e39579e4a411e70f5b6a08763e703000000000017a9143638b2602d11f934c04abc6adb1494f69d1f14af8702473044022059ddd943b399211e4266a349f26b3289979e29f9b067792c6cfa8cc5ae25f44602204d627a5a5b603d0562e7969011fb3d64908af90a3ec7c876eaa9baf61e1958af012102f5188df1da92ed818581c29778047800ed6635788aa09d9469f7d17628f7323300000000" + ) + // This is random, longer mainnet transaction. + val tx2 = Transaction.read( + "0200000000010142180a8812fc79a3da7fb2471eff3e22d7faee990604c2ba7f2fc8dfb15b550a0200000000feffffff030f241800000000001976a9146774040642a78ca3b8b395e70f8391b21ec026fc88ac4a155801000000001600148d2e0b57adcb8869e603fd35b5179caf053361253b1d010000000000160014e032f4f4b9f8611df0d30a20648c190c263bbc33024730440220506005aa347f5b698542cafcb4f1a10250aeb52a609d6fd67ef68f9c1a5d954302206b9bb844343f4012bccd9d08a0f5430afb9549555a3252e499be7df97aae477a012103976d6b3eea3de4b056cd88cdfd50a22daf121e0fb5c6e45ba0f40e1effbd275a00000000" + ) + val testCases = listOf( + // @formatter:off + TxAddInput(channelId1, 561, tx1, 1, 5) to ByteVector("0042 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000231 00f7 020000000001014ade359c5deb7c1cde2e94f401854658f97d7fa31c17ce9a831db253120a0a410100000017160014eb9a5bd79194a23d19d6ec473c768fb74f9ed32cffffffff021ca408000000000017a914946118f24bb7b37d5e9e39579e4a411e70f5b6a08763e703000000000017a9143638b2602d11f934c04abc6adb1494f69d1f14af8702473044022059ddd943b399211e4266a349f26b3289979e29f9b067792c6cfa8cc5ae25f44602204d627a5a5b603d0562e7969011fb3d64908af90a3ec7c876eaa9baf61e1958af012102f5188df1da92ed818581c29778047800ed6635788aa09d9469f7d17628f7323300000000 00000001 00000005"), + TxAddInput(channelId2, 0, tx2, 2, 0) to ByteVector("0042 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 0000000000000000 0100 0200000000010142180a8812fc79a3da7fb2471eff3e22d7faee990604c2ba7f2fc8dfb15b550a0200000000feffffff030f241800000000001976a9146774040642a78ca3b8b395e70f8391b21ec026fc88ac4a155801000000001600148d2e0b57adcb8869e603fd35b5179caf053361253b1d010000000000160014e032f4f4b9f8611df0d30a20648c190c263bbc33024730440220506005aa347f5b698542cafcb4f1a10250aeb52a609d6fd67ef68f9c1a5d954302206b9bb844343f4012bccd9d08a0f5430afb9549555a3252e499be7df97aae477a012103976d6b3eea3de4b056cd88cdfd50a22daf121e0fb5c6e45ba0f40e1effbd275a00000000 00000002 00000000"), + TxAddInput(channelId1, 561, tx1, 0, 0) to ByteVector("0042 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000231 00f7 020000000001014ade359c5deb7c1cde2e94f401854658f97d7fa31c17ce9a831db253120a0a410100000017160014eb9a5bd79194a23d19d6ec473c768fb74f9ed32cffffffff021ca408000000000017a914946118f24bb7b37d5e9e39579e4a411e70f5b6a08763e703000000000017a9143638b2602d11f934c04abc6adb1494f69d1f14af8702473044022059ddd943b399211e4266a349f26b3289979e29f9b067792c6cfa8cc5ae25f44602204d627a5a5b603d0562e7969011fb3d64908af90a3ec7c876eaa9baf61e1958af012102f5188df1da92ed818581c29778047800ed6635788aa09d9469f7d17628f7323300000000 00000000 00000000"), + TxAddOutput(channelId1, 1105, 2047.sat, ByteVector("00149357014afd0ccd265658c9ae81efa995e771f472")) to ByteVector("0043 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000451 00000000000007ff 0016 00149357014afd0ccd265658c9ae81efa995e771f472"), + TxRemoveInput(channelId2, 561) to ByteVector("0044 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 0000000000000231"), + TxRemoveOutput(channelId1, 1) to ByteVector("0045 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000001"), + TxComplete(channelId1) to ByteVector("0046 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + TxSignatures(channelId1, tx2.txid, listOf(ScriptWitness(listOf(ByteVector("dead"), ByteVector("beef"))), ScriptWitness(listOf(ByteVector(""), ByteVector("01010101"), ByteVector(""), ByteVector("02"))))) to ByteVector("0047 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa f169ed4bcb4ca97646845ec063d4deddcbe704f77f1b2c205929195f84a87afc 0002 00020002dead0002beef 0004 00000004010101010000000102"), + TxSignatures(channelId2, tx1.txid, listOf()) to ByteVector("0047 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 06f125a8ef64eb5a25826190dc28f15b85dc1adcfc7a178eef393ea325c02e1f 0000"), + TxInitRbf(channelId1, 8388607, FeeratePerKw(4000.sat)) to ByteVector("0048 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 007fffff 00000fa0"), + TxInitRbf(channelId1, 0, FeeratePerKw(4000.sat), TlvStream(listOf(TxInitRbfTlv.SharedOutputContributionTlv(5000.sat)))) to ByteVector("0048 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000 00000fa0 00021388"), + TxAckRbf(channelId2) to ByteVector("0049 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + TxAckRbf(channelId2, TlvStream(listOf(TxAckRbfTlv.SharedOutputContributionTlv(450_000.sat)))) to ByteVector("0049 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 000306ddd0"), + TxAbort(channelId1, "") to ByteVector("004a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000"), + TxAbort(channelId1, "internal error") to ByteVector("004a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 000e 696e7465726e616c206572726f72"), + // @formatter:on + ) + testCases.forEach { (message, bin) -> + val decoded = LightningMessage.decode(bin.toByteArray()) + assertNotNull(decoded) + assertEquals(decoded, message) + val encoded = LightningMessage.encode(message) + assertEquals(encoded.byteVector(), bin) + } + } + @Test fun `decode invalid open_channel`() { val defaultEncoded =