diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt index 7f2575ead..b5a52570f 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt @@ -8,6 +8,7 @@ import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.blockchain.fee.FeeratePerByte import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.blockchain.fee.FeerateTolerance +import fr.acinq.lightning.channel.Commitments.Companion.makeLocalTxs import fr.acinq.lightning.channel.states.Channel import fr.acinq.lightning.channel.states.ChannelContext import fr.acinq.lightning.crypto.Bolt3Derivation.deriveForCommitment @@ -93,7 +94,52 @@ data class HtlcTxAndSigs(val txinfo: HtlcTx, val localSig: ByteVector64, val rem data class PublishableTxs(val commitTx: CommitTx, val htlcTxsAndSigs: List) /** The local commitment maps to a commitment transaction that we can sign and broadcast if necessary. */ -data class LocalCommit(val index: Long, val spec: CommitmentSpec, val publishableTxs: PublishableTxs) +data class LocalCommit(val index: Long, val spec: CommitmentSpec, val publishableTxs: PublishableTxs) { + companion object { + fun fromCommitSig(keyManager: KeyManager.ChannelKeys, params: ChannelParams, fundingTxIndex: Long, + remoteFundingPubKey: PublicKey, commitInput: Transactions.InputInfo, commit: CommitSig, + localCommitIndex: Long, spec: CommitmentSpec, localPerCommitmentPoint: PublicKey, log: MDCLogger): Either { + val (localCommitTx, htlcTxs) = makeLocalTxs(keyManager, localCommitIndex, params.localParams, params.remoteParams, fundingTxIndex, remoteFundingPubKey, commitInput, localPerCommitmentPoint, spec) + val sig = Transactions.sign(localCommitTx, keyManager.fundingKey(fundingTxIndex)) + + // no need to compute htlc sigs if commit sig doesn't check out + val signedCommitTx = Transactions.addSigs(localCommitTx, keyManager.fundingPubKey(fundingTxIndex), remoteFundingPubKey, sig, commit.signature) + when (val check = Transactions.checkSpendable(signedCommitTx)) { + is Try.Failure -> { + log.error(check.error) { "remote signature $commit is invalid" } + return Either.Left(InvalidCommitmentSignature(params.channelId, signedCommitTx.tx.txid)) + } + else -> {} + } + val sortedHtlcTxs: List = htlcTxs.sortedBy { it.input.outPoint.index } + if (commit.htlcSignatures.size != sortedHtlcTxs.size) { + return Either.Left(HtlcSigCountMismatch(params.channelId, sortedHtlcTxs.size, commit.htlcSignatures.size)) + } + val htlcSigs = sortedHtlcTxs.map { Transactions.sign(it, keyManager.htlcKey.deriveForCommitment(localPerCommitmentPoint), SigHash.SIGHASH_ALL) } + val remoteHtlcPubkey = params.remoteParams.htlcBasepoint.deriveForCommitment(localPerCommitmentPoint) + // combine the sigs to make signed txs + val htlcTxsAndSigs = Triple(sortedHtlcTxs, htlcSigs, commit.htlcSignatures).zipped().map { (htlcTx, localSig, remoteSig) -> + when (htlcTx) { + is HtlcTx.HtlcTimeoutTx -> { + if (Transactions.checkSpendable(Transactions.addSigs(htlcTx, localSig, remoteSig)).isFailure) { + return Either.Left(InvalidHtlcSignature(params.channelId, htlcTx.tx.txid)) + } + HtlcTxAndSigs(htlcTx, localSig, remoteSig) + } + is HtlcTx.HtlcSuccessTx -> { + // we can't check that htlc-success tx are spendable because we need the payment preimage; thus we only check the remote sig + // which was created with SIGHASH_SINGLE || SIGHASH_ANYONECANPAY + if (!Transactions.checkSig(htlcTx, remoteSig, remoteHtlcPubkey, SigHash.SIGHASH_SINGLE or SigHash.SIGHASH_ANYONECANPAY)) { + return Either.Left(InvalidHtlcSignature(params.channelId, htlcTx.tx.txid)) + } + HtlcTxAndSigs(htlcTx, localSig, remoteSig) + } + } + } + return Either.Right(LocalCommit(localCommitIndex, spec, PublishableTxs(signedCommitTx, htlcTxsAndSigs))) + } + } +} /** The remote commitment maps to a commitment transaction that only our peer can sign and broadcast. */ data class RemoteCommit(val index: Long, val spec: CommitmentSpec, val txid: ByteVector32, val remotePerCommitmentPoint: PublicKey) { @@ -444,52 +490,15 @@ data class Commitment( // receiving money i.e its commit tx has one output for them val spec = CommitmentSpec.reduce(localCommit.spec, changes.localChanges.acked, changes.remoteChanges.proposed) val localPerCommitmentPoint = channelKeys.commitmentPoint(localCommit.index + 1) - val (localCommitTx, htlcTxs) = Commitments.makeLocalTxs(channelKeys, commitTxNumber = localCommit.index + 1, params.localParams, params.remoteParams, fundingTxIndex = fundingTxIndex, remoteFundingPubKey = remoteFundingPubkey, commitInput, localPerCommitmentPoint = localPerCommitmentPoint, spec) - val sig = Transactions.sign(localCommitTx, channelKeys.fundingKey(fundingTxIndex)) - log.info { - val htlcsIn = spec.htlcs.incomings().map { it.id }.joinToString(",") - val htlcsOut = spec.htlcs.outgoings().map { it.id }.joinToString(",") - "built local commit number=${localCommit.index + 1} toLocalMsat=${spec.toLocal.toLong()} toRemoteMsat=${spec.toRemote.toLong()} htlc_in=$htlcsIn htlc_out=$htlcsOut feeratePerKw=${spec.feerate} txId=${localCommitTx.tx.txid} fundingTxId=$fundingTxId" - } - - // no need to compute htlc sigs if commit sig doesn't check out - val signedCommitTx = Transactions.addSigs(localCommitTx, channelKeys.fundingPubKey(fundingTxIndex), remoteFundingPubkey, sig, commit.signature) - when (val check = Transactions.checkSpendable(signedCommitTx)) { - is Try.Failure -> { - log.error(check.error) { "remote signature $commit is invalid" } - return Either.Left(InvalidCommitmentSignature(params.channelId, signedCommitTx.tx.txid)) - } - else -> {} - } - - val sortedHtlcTxs: List = htlcTxs.sortedBy { it.input.outPoint.index } - if (commit.htlcSignatures.size != sortedHtlcTxs.size) { - return Either.Left(HtlcSigCountMismatch(params.channelId, sortedHtlcTxs.size, commit.htlcSignatures.size)) - } - val htlcSigs = sortedHtlcTxs.map { Transactions.sign(it, channelKeys.htlcKey.deriveForCommitment(localPerCommitmentPoint), SigHash.SIGHASH_ALL) } - val remoteHtlcPubkey = params.remoteParams.htlcBasepoint.deriveForCommitment(localPerCommitmentPoint) - // combine the sigs to make signed txs - val htlcTxsAndSigs = Triple(sortedHtlcTxs, htlcSigs, commit.htlcSignatures).zipped().map { (htlcTx, localSig, remoteSig) -> - when (htlcTx) { - is HtlcTx.HtlcTimeoutTx -> { - if (Transactions.checkSpendable(Transactions.addSigs(htlcTx, localSig, remoteSig)).isFailure) { - return Either.Left(InvalidHtlcSignature(params.channelId, htlcTx.tx.txid)) - } - HtlcTxAndSigs(htlcTx, localSig, remoteSig) - } - is HtlcTx.HtlcSuccessTx -> { - // we can't check that htlc-success tx are spendable because we need the payment preimage; thus we only check the remote sig - // which was created with SIGHASH_SINGLE || SIGHASH_ANYONECANPAY - if (!Transactions.checkSig(htlcTx, remoteSig, remoteHtlcPubkey, SigHash.SIGHASH_SINGLE or SigHash.SIGHASH_ANYONECANPAY)) { - return Either.Left(InvalidHtlcSignature(params.channelId, htlcTx.tx.txid)) - } - HtlcTxAndSigs(htlcTx, localSig, remoteSig) - } + return LocalCommit.fromCommitSig(channelKeys, params, fundingTxIndex, remoteFundingPubkey, commitInput, commit, localCommit.index + 1, spec, localPerCommitmentPoint, log).map { localCommit1 -> + log.info { + val htlcsIn = spec.htlcs.outgoings().map { it.id }.joinToString(",") + val htlcsOut = spec.htlcs.incomings().map { it.id }.joinToString(",") + "built local commit number=${localCommit.index + 1} toLocalMsat=${spec.toLocal.toLong()} toRemoteMsat=${spec.toRemote.toLong()} htlc_in=$htlcsIn htlc_out=$htlcsOut feeratePerKw=${spec.feerate} txid=${localCommit1.publishableTxs.commitTx.tx.txid} fundingTxId=$fundingTxId" } + copy(localCommit = localCommit1) } - val localCommit1 = LocalCommit(localCommit.index + 1, spec, PublishableTxs(signedCommitTx, htlcTxsAndSigs)) - return Either.Right(copy(localCommit = localCommit1)) } } @@ -513,6 +522,7 @@ data class FullCommitment( params.channelFeatures.hasFeature(Feature.ZeroReserveChannels) -> 0.sat else -> (fundingAmount / 100).max(params.localParams.dustLimit) } + val capacity = commitInput.txOut.amount } data class WaitingForRevocation(val sentAfterLocalCommitIndex: Long) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt index eba01af52..a5510b1e6 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt @@ -273,14 +273,14 @@ object Helpers { ) } - data class PairOfCommitTxs(val localSpec: CommitmentSpec, val localCommitTx: Transactions.TransactionWithInputInfo.CommitTx, val remoteSpec: CommitmentSpec, val remoteCommitTx: Transactions.TransactionWithInputInfo.CommitTx) + data class PairOfCommitTxs(val localSpec: CommitmentSpec, val localCommitTx: Transactions.TransactionWithInputInfo.CommitTx, val localHtlcTxs: List, val remoteSpec: CommitmentSpec, val remoteCommitTx: Transactions.TransactionWithInputInfo.CommitTx, val remoteHtlcTxs: List) /** * Creates both sides' first commitment transaction. * * @return (localSpec, localTx, remoteSpec, remoteTx, fundingTxOutput) */ - fun makeCommitTxsWithoutHtlcs( + fun makeCommitTxs( channelKeys: KeyManager.ChannelKeys, channelId: ByteVector32, localParams: LocalParams, @@ -288,6 +288,7 @@ object Helpers { fundingAmount: Satoshi, toLocal: MilliSatoshi, toRemote: MilliSatoshi, + localHtlcs: Set, localCommitmentIndex: Long, remoteCommitmentIndex: Long, commitTxFeerate: FeeratePerKw, @@ -297,8 +298,8 @@ object Helpers { remoteFundingPubkey: PublicKey, remotePerCommitmentPoint: PublicKey ): Either { - val localSpec = CommitmentSpec(setOf(), commitTxFeerate, toLocal = toLocal, toRemote = toRemote) - val remoteSpec = CommitmentSpec(setOf(), commitTxFeerate, toLocal = toRemote, toRemote = toLocal) + val localSpec = CommitmentSpec(localHtlcs, commitTxFeerate, toLocal = toLocal, toRemote = toRemote) + val remoteSpec = CommitmentSpec(localHtlcs.map{ it.opposite() }.toSet(), commitTxFeerate, toLocal = toRemote, toRemote = toLocal) if (!localParams.isInitiator) { // They initiated the channel open, therefore they pay the fee: we need to make sure they can afford it! @@ -315,7 +316,7 @@ object Helpers { val fundingPubKey = channelKeys.fundingPubKey(fundingTxIndex) val commitmentInput = makeFundingInputInfo(fundingTxHash, fundingTxOutputIndex, fundingAmount, fundingPubKey, remoteFundingPubkey) val localPerCommitmentPoint = channelKeys.commitmentPoint(localCommitmentIndex) - val localCommitTx = Commitments.makeLocalTxs( + val (localCommitTx, localHtlcTxs) = Commitments.makeLocalTxs( channelKeys, commitTxNumber = localCommitmentIndex, localParams, @@ -325,8 +326,8 @@ object Helpers { commitmentInput, localPerCommitmentPoint = localPerCommitmentPoint, localSpec - ).first - val remoteCommitTx = Commitments.makeRemoteTxs( + ) + val (remoteCommitTx, remoteHtlcTxs) = Commitments.makeRemoteTxs( channelKeys, commitTxNumber = remoteCommitmentIndex, localParams, @@ -336,9 +337,9 @@ object Helpers { commitmentInput, remotePerCommitmentPoint = remotePerCommitmentPoint, remoteSpec - ).first + ) - return Either.Right(PairOfCommitTxs(localSpec, localCommitTx, remoteSpec, remoteCommitTx)) + return Either.Right(PairOfCommitTxs(localSpec, localCommitTx, localHtlcTxs, remoteSpec, remoteCommitTx, remoteHtlcTxs)) } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt index 987e27672..aac5c7240 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt @@ -5,8 +5,10 @@ import fr.acinq.bitcoin.Script.tail import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.blockchain.electrum.WalletState import fr.acinq.lightning.blockchain.fee.FeeratePerKw +import fr.acinq.lightning.crypto.Bolt3Derivation.deriveForCommitment import fr.acinq.lightning.crypto.KeyManager import fr.acinq.lightning.transactions.CommitmentSpec +import fr.acinq.lightning.transactions.DirectedHtlc import fr.acinq.lightning.transactions.Scripts import fr.acinq.lightning.transactions.Transactions import fr.acinq.lightning.utils.* @@ -21,14 +23,16 @@ import kotlinx.coroutines.CompletableDeferred sealed class SharedFundingInput { abstract val info: Transactions.InputInfo abstract val weight: Int + abstract val localHtlcs: Set abstract fun sign(channelKeys: KeyManager.ChannelKeys, tx: Transaction): ByteVector64 - data class Multisig2of2(override val info: Transactions.InputInfo, val fundingTxIndex: Long, val remoteFundingPubkey: PublicKey) : SharedFundingInput() { + data class Multisig2of2(override val info: Transactions.InputInfo, val fundingTxIndex: Long, val remoteFundingPubkey: PublicKey, override val localHtlcs: Set) : SharedFundingInput() { constructor(commitment: Commitment) : this( info = commitment.commitInput, fundingTxIndex = commitment.fundingTxIndex, - remoteFundingPubkey = commitment.remoteFundingPubkey + remoteFundingPubkey = commitment.remoteFundingPubkey, + localHtlcs = commitment.localCommit.spec.htlcs ) // This value was computed assuming 73 bytes signatures (worst-case scenario). @@ -46,8 +50,9 @@ sealed class SharedFundingInput { } /** The current balances of a [[SharedFundingInput]]. */ -data class SharedFundingInputBalances(val toLocal: MilliSatoshi, val toRemote: MilliSatoshi) { - val fundingAmount: Satoshi = (toLocal + toRemote).truncateToSatoshi() +data class SharedFundingInputBalances(val toLocal: MilliSatoshi, val toRemote: MilliSatoshi, val htlcs: Set) { + val toHtlc = htlcs.map { it.add.amountMsat }.sum() + val fundingAmount: Satoshi = (toLocal + toRemote + toHtlc).truncateToSatoshi() } /** @@ -131,7 +136,7 @@ sealed class InteractiveTxInput { data class RemoteSwapIn(override val serialId: Long, override val outPoint: OutPoint, override val txOut: TxOut, override val sequence: UInt, val userKey: PublicKey, val serverKey: PublicKey, val refundDelay: Int) : Remote() /** The shared input can be added by us or by our peer, depending on who initiated the protocol. */ - data class Shared(override val serialId: Long, override val outPoint: OutPoint, override val sequence: UInt, val localAmount: MilliSatoshi, val remoteAmount: MilliSatoshi) : InteractiveTxInput(), Incoming, Outgoing + data class Shared(override val serialId: Long, override val outPoint: OutPoint, override val sequence: UInt, val localAmount: MilliSatoshi, val remoteAmount: MilliSatoshi, val localHtlcs: Set) : InteractiveTxInput(), Incoming, Outgoing } sealed class InteractiveTxOutput { @@ -155,9 +160,9 @@ sealed class InteractiveTxOutput { data class Remote(override val serialId: Long, override val amount: Satoshi, override val pubkeyScript: ByteVector) : InteractiveTxOutput(), Incoming /** The shared output can be added by us or by our peer, depending on who initiated the protocol. */ - data class Shared(override val serialId: Long, override val pubkeyScript: ByteVector, val localAmount: MilliSatoshi, val remoteAmount: MilliSatoshi) : InteractiveTxOutput(), Incoming, Outgoing { + data class Shared(override val serialId: Long, override val pubkeyScript: ByteVector, val localAmount: MilliSatoshi, val remoteAmount: MilliSatoshi, val htlcAmount: MilliSatoshi) : InteractiveTxOutput(), Incoming, Outgoing { // Note that the truncation is a no-op: the sum of balances in a channel must be a satoshi amount. - override val amount: Satoshi = (localAmount + remoteAmount).truncateToSatoshi() + override val amount: Satoshi = (localAmount + remoteAmount + htlcAmount).truncateToSatoshi() } } @@ -232,7 +237,8 @@ data class FundingContributions(val inputs: List, v return Either.Left(FundingContributionFailure.InvalidFundingBalances(params.fundingAmount, nextLocalBalance, nextRemoteBalance)) } - val sharedOutput = listOf(InteractiveTxOutput.Shared(0, fundingPubkeyScript, nextLocalBalance, nextRemoteBalance)) + val nextHtlcBalance = (sharedUtxo?.second?.toHtlc ?: 0.msat) + val sharedOutput = listOf(InteractiveTxOutput.Shared(0, fundingPubkeyScript, nextLocalBalance, nextRemoteBalance, nextHtlcBalance)) val nonChangeOutputs = localOutputs.map { o -> InteractiveTxOutput.Local.NonChange(0, o.amount, o.publicKeyScript) } val changeOutput = when (changePubKey) { null -> listOf() @@ -245,7 +251,7 @@ data class FundingContributions(val inputs: List, v } } } - val sharedInput = sharedUtxo?.let { (i, balances) -> listOf(InteractiveTxInput.Shared(0, i.info.outPoint, 0xfffffffdU, balances.toLocal, balances.toRemote)) } ?: listOf() + val sharedInput = sharedUtxo?.let { (i, balances) -> listOf(InteractiveTxInput.Shared(0, i.info.outPoint, 0xfffffffdU, balances.toLocal, balances.toRemote, i.localHtlcs)) } ?: listOf() val localInputs = walletInputs.map { i -> InteractiveTxInput.LocalSwapIn(0, i.previousTx.stripInputWitnesses(), i.outputIndex.toLong(), 0xfffffffdU, swapInKeys.userPublicKey, swapInKeys.remoteServerPublicKey, swapInKeys.refundDelay) } return if (params.isInitiator) { Either.Right(sortFundingContributions(params, sharedInput + localInputs, sharedOutput + nonChangeOutputs + changeOutput)) @@ -275,7 +281,7 @@ data class FundingContributions(val inputs: List, v fun computeWeightPaid(isInitiator: Boolean, commitment: Commitment, walletInputs: List, localOutputs: List): Int = computeWeightPaid( isInitiator, - SharedFundingInput.Multisig2of2(commitment.commitInput, commitment.fundingTxIndex, Transactions.PlaceHolderPubKey), + SharedFundingInput.Multisig2of2(commitment.commitInput, commitment.fundingTxIndex, Transactions.PlaceHolderPubKey, commitment.localCommit.spec.htlcs), commitment.commitInput.txOut.publicKeyScript, walletInputs, localOutputs @@ -492,13 +498,14 @@ data class InteractiveTxSession( fundingParams: InteractiveTxParams, previousLocalBalance: MilliSatoshi, previousRemoteBalance: MilliSatoshi, + previousHtlcs: Set, fundingContributions: FundingContributions, previousTxs: List = listOf() ) : this( channelKeys, swapInKeys, fundingParams, - SharedFundingInputBalances(previousLocalBalance, previousRemoteBalance), + SharedFundingInputBalances(previousLocalBalance, previousRemoteBalance, previousHtlcs), fundingContributions.inputs.map { i -> Either.Left(i) } + fundingContributions.outputs.map { o -> Either.Right(o) }, previousTxs ) @@ -550,7 +557,7 @@ data class InteractiveTxSession( val expectedSharedOutpoint = fundingParams.sharedInput?.info?.outPoint ?: return Either.Left(InteractiveTxSessionAction.PreviousTxMissing(message.channelId, message.serialId)) val receivedSharedOutpoint = message.sharedInput ?: return Either.Left(InteractiveTxSessionAction.PreviousTxMissing(message.channelId, message.serialId)) if (expectedSharedOutpoint != receivedSharedOutpoint) return Either.Left(InteractiveTxSessionAction.PreviousTxMissing(message.channelId, message.serialId)) - InteractiveTxInput.Shared(message.serialId, receivedSharedOutpoint, message.sequence, previousFunding.toLocal, previousFunding.toRemote) + InteractiveTxInput.Shared(message.serialId, receivedSharedOutpoint, message.sequence, previousFunding.toLocal, previousFunding.toRemote, previousFunding.htlcs) } else -> { if (message.previousTx.txOut.size <= message.previousTxOutput) { @@ -593,7 +600,7 @@ data class InteractiveTxSession( } else if (message.pubkeyScript == fundingParams.fundingPubkeyScript(channelKeys)) { val localAmount = previousFunding.toLocal + fundingParams.localContribution.toMilliSatoshi() val remoteAmount = previousFunding.toRemote + fundingParams.remoteContribution.toMilliSatoshi() - Either.Right(InteractiveTxOutput.Shared(message.serialId, message.pubkeyScript, localAmount, remoteAmount)) + Either.Right(InteractiveTxOutput.Shared(message.serialId, message.pubkeyScript, localAmount, remoteAmount, previousFunding.toHtlc)) } else { Either.Right(InteractiveTxOutput.Remote(message.serialId, message.amount, message.pubkeyScript)) } @@ -771,27 +778,28 @@ data class InteractiveTxSigningSession( fun receiveCommitSig(channelKeys: KeyManager.ChannelKeys, channelParams: ChannelParams, remoteCommitSig: CommitSig, currentBlockHeight: Long, logger: MDCLogger): Pair { return when (localCommit) { is Either.Left -> { - val fundingKey = channelKeys.fundingKey(fundingTxIndex) - val localSigOfLocalTx = Transactions.sign(localCommit.value.commitTx, fundingKey) - val signedLocalCommitTx = Transactions.addSigs(localCommit.value.commitTx, fundingKey.publicKey(), fundingParams.remoteFundingPubkey, localSigOfLocalTx, remoteCommitSig.signature) - when (Transactions.checkSpendable(signedLocalCommitTx)) { - is Try.Failure -> { + val localCommitIndex = localCommit.value.index + val localPerCommitmentPoint = channelKeys.commitmentPoint(localCommitIndex) + when (val signedLocalCommit = LocalCommit.fromCommitSig(channelKeys, channelParams, fundingTxIndex, fundingParams.remoteFundingPubkey, commitInput, remoteCommitSig, localCommitIndex, localCommit.value.spec, localPerCommitmentPoint, logger)) { + is Either.Left -> { + val fundingKey = channelKeys.fundingKey(fundingTxIndex) + val localSigOfLocalTx = Transactions.sign(localCommit.value.commitTx, fundingKey) + val signedLocalCommitTx = Transactions.addSigs(localCommit.value.commitTx, fundingKey.publicKey(), fundingParams.remoteFundingPubkey, localSigOfLocalTx, remoteCommitSig.signature) logger.info { "interactiveTxSession=$this" } logger.info { "channelParams=$channelParams" } logger.info { "fundingKey=${fundingKey.publicKey()}" } logger.info { "localSigOfLocalTx=$localSigOfLocalTx" } logger.info { "signedLocalCommitTx=$signedLocalCommitTx" } - Pair(this, InteractiveTxSigningSessionAction.AbortFundingAttempt(InvalidCommitmentSignature(fundingParams.channelId, signedLocalCommitTx.tx.txid))) + Pair(this, InteractiveTxSigningSessionAction.AbortFundingAttempt(signedLocalCommit.value)) } - is Try.Success -> { - val signedLocalCommit = LocalCommit(localCommit.value.index, localCommit.value.spec, PublishableTxs(signedLocalCommitTx, listOf())) + is Either.Right -> { if (shouldSignFirst(channelParams, fundingTx.tx)) { val fundingStatus = LocalFundingStatus.UnconfirmedFundingTx(fundingTx, fundingParams, currentBlockHeight) - val commitment = Commitment(fundingTxIndex, fundingParams.remoteFundingPubkey, fundingStatus, RemoteFundingStatus.NotLocked, signedLocalCommit, remoteCommit, nextRemoteCommit = null) + val commitment = Commitment(fundingTxIndex, fundingParams.remoteFundingPubkey, fundingStatus, RemoteFundingStatus.NotLocked, signedLocalCommit.value, remoteCommit, nextRemoteCommit = null) val action = InteractiveTxSigningSessionAction.SendTxSigs(fundingStatus, commitment, fundingTx.localSigs) - Pair(this.copy(localCommit = Either.Right(signedLocalCommit)), action) + Pair(this.copy(localCommit = Either.Right(signedLocalCommit.value)), action) } else { - Pair(this.copy(localCommit = Either.Right(signedLocalCommit)), InteractiveTxSigningSessionAction.WaitForTxSigs) + Pair(this.copy(localCommit = Either.Right(signedLocalCommit.value)), InteractiveTxSigningSessionAction.WaitForTxSigs) } } } @@ -834,13 +842,14 @@ data class InteractiveTxSigningSession( val channelKeys = channelParams.localParams.channelKeys(keyManager) val unsignedTx = sharedTx.buildUnsignedTx() val sharedOutputIndex = unsignedTx.txOut.indexOfFirst { it.publicKeyScript == fundingParams.fundingPubkeyScript(channelKeys) } - return Helpers.Funding.makeCommitTxsWithoutHtlcs( + return Helpers.Funding.makeCommitTxs( channelKeys, channelParams.channelId, channelParams.localParams, channelParams.remoteParams, fundingAmount = sharedTx.sharedOutput.amount, toLocal = sharedTx.sharedOutput.localAmount - localPushAmount + remotePushAmount, toRemote = sharedTx.sharedOutput.remoteAmount - remotePushAmount + localPushAmount, + localHtlcs = sharedTx.sharedInput?.localHtlcs ?: setOf(), localCommitmentIndex = localCommitmentIndex, remoteCommitmentIndex = remoteCommitmentIndex, commitTxFeerate, @@ -848,25 +857,33 @@ data class InteractiveTxSigningSession( remoteFundingPubkey = fundingParams.remoteFundingPubkey, remotePerCommitmentPoint = remotePerCommitmentPoint ).map { firstCommitTx -> - val localSigOfRemoteTx = Transactions.sign(firstCommitTx.remoteCommitTx, channelKeys.fundingKey(fundingTxIndex)) - val alternativeSigs = Commitments.alternativeFeerates.map { feerate -> - val alternativeSpec = firstCommitTx.remoteSpec.copy(feerate = feerate) - val (alternativeRemoteCommitTx, _) = Commitments.makeRemoteTxs( - channelKeys, - remoteCommitmentIndex, - channelParams.localParams, - channelParams.remoteParams, - fundingTxIndex, - fundingParams.remoteFundingPubkey, - firstCommitTx.remoteCommitTx.input, - remotePerCommitmentPoint, - alternativeSpec - ) - val alternativeSig = Transactions.sign(alternativeRemoteCommitTx, channelKeys.fundingKey(fundingTxIndex)) - CommitSigTlv.AlternativeFeerateSig(feerate, alternativeSig) + val localSigOfRemoteCommitTx = Transactions.sign(firstCommitTx.remoteCommitTx, channelKeys.fundingKey(fundingTxIndex)) + val sortedHtlcTxs = firstCommitTx.remoteHtlcTxs.sortedBy { it.input.outPoint.index } + val localSigsOfRemoteHtlcTxs = sortedHtlcTxs.map { Transactions.sign(it, channelKeys.htlcKey.deriveForCommitment(remotePerCommitmentPoint), SigHash.SIGHASH_SINGLE or SigHash.SIGHASH_ANYONECANPAY) } + + val alternativeSigs = if (firstCommitTx.remoteHtlcTxs.isEmpty()) { + val commitSigTlvs = Commitments.alternativeFeerates.map { feerate -> + val alternativeSpec = firstCommitTx.remoteSpec.copy(feerate = feerate) + val (alternativeRemoteCommitTx, _) = Commitments.makeRemoteTxs( + channelKeys, + remoteCommitmentIndex, + channelParams.localParams, + channelParams.remoteParams, + fundingTxIndex, + fundingParams.remoteFundingPubkey, + firstCommitTx.remoteCommitTx.input, + remotePerCommitmentPoint, + alternativeSpec + ) + val sig = Transactions.sign(alternativeRemoteCommitTx, channelKeys.fundingKey(fundingTxIndex)) + CommitSigTlv.AlternativeFeerateSig(feerate, sig) + } + TlvStream(CommitSigTlv.AlternativeFeerateSigs(commitSigTlvs) as CommitSigTlv) + } else { + TlvStream.empty() } - val commitSig = CommitSig(channelParams.channelId, localSigOfRemoteTx, listOf(), TlvStream(CommitSigTlv.AlternativeFeerateSigs(alternativeSigs))) - val unsignedLocalCommit = UnsignedLocalCommit(localCommitmentIndex, firstCommitTx.localSpec, firstCommitTx.localCommitTx, listOf()) + val commitSig = CommitSig(channelParams.channelId, localSigOfRemoteCommitTx, localSigsOfRemoteHtlcTxs, alternativeSigs) + val unsignedLocalCommit = UnsignedLocalCommit(localCommitmentIndex, firstCommitTx.localSpec, firstCommitTx.localCommitTx, sortedHtlcTxs) val remoteCommit = RemoteCommit(remoteCommitmentIndex, firstCommitTx.remoteSpec, firstCommitTx.remoteCommitTx.tx.txid, remotePerCommitmentPoint) val signedFundingTx = sharedTx.sign(keyManager, fundingParams, channelParams.localParams, channelParams.remoteParams.nodeId) Pair(InteractiveTxSigningSession(fundingParams, fundingTxIndex, signedFundingTx, Either.Left(unsignedLocalCommit), remoteCommit), commitSig) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt index 6f0c35828..683584672 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt @@ -471,6 +471,7 @@ data class Normal( fundingParams, previousLocalBalance = parentCommitment.localCommit.spec.toLocal, previousRemoteBalance = parentCommitment.localCommit.spec.toRemote, + previousHtlcs = parentCommitment.localCommit.spec.htlcs, fundingContributions = FundingContributions(emptyList(), emptyList()), // as non-initiator we don't contribute to this splice for now previousTxs = emptyList() ) @@ -510,7 +511,7 @@ data class Normal( channelKeys = channelKeys(), swapInKeys = keyManager.swapInOnChainWallet, params = fundingParams, - sharedUtxo = Pair(sharedInput, SharedFundingInputBalances(toLocal = parentCommitment.localCommit.spec.toLocal, toRemote = parentCommitment.localCommit.spec.toRemote)), + sharedUtxo = Pair(sharedInput, SharedFundingInputBalances(toLocal = parentCommitment.localCommit.spec.toLocal, toRemote = parentCommitment.localCommit.spec.toRemote, htlcs = parentCommitment.localCommit.spec.htlcs)), walletInputs = spliceStatus.command.spliceIn?.walletInputs ?: emptyList(), localOutputs = spliceStatus.command.spliceOutputs, changePubKey = null // we don't want a change output: we're spending every funds available @@ -528,6 +529,7 @@ data class Normal( fundingParams, previousLocalBalance = parentCommitment.localCommit.spec.toLocal, previousRemoteBalance = parentCommitment.localCommit.spec.toRemote, + previousHtlcs = parentCommitment.localCommit.spec.htlcs, fundingContributions.value, previousTxs = emptyList() ).send() when (interactiveTxAction) { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt index 02cf439ad..df4a37c19 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt @@ -59,7 +59,7 @@ data class WaitForAcceptChannel( } is Either.Right -> { // The channel initiator always sends the first interactive-tx message. - val (interactiveTxSession, interactiveTxAction) = InteractiveTxSession(channelKeys, keyManager.swapInOnChainWallet, fundingParams, 0.msat, 0.msat, fundingContributions.value).send() + val (interactiveTxSession, interactiveTxAction) = InteractiveTxSession(channelKeys, keyManager.swapInOnChainWallet, fundingParams, 0.msat, 0.msat, emptySet(), fundingContributions.value).send() when (interactiveTxAction) { is InteractiveTxSessionAction.SendMessage -> { val nextState = WaitForFundingCreated( diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmed.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmed.kt index 06e9c8f35..e015c46d7 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmed.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmed.kt @@ -107,7 +107,7 @@ data class WaitForFundingConfirmed( addAll(latestFundingTx.sharedTx.tx.localInputs.map { Either.Left(it) }) addAll(latestFundingTx.sharedTx.tx.localOutputs.map { Either.Right(it) }) } - val session = InteractiveTxSession(channelKeys(), keyManager.swapInOnChainWallet, fundingParams, SharedFundingInputBalances(0.msat, 0.msat), toSend, previousFundingTxs.map { it.sharedTx }) + val session = InteractiveTxSession(channelKeys(), keyManager.swapInOnChainWallet, fundingParams, SharedFundingInputBalances(0.msat, 0.msat, emptySet()), toSend, previousFundingTxs.map { it.sharedTx }) val nextState = this@WaitForFundingConfirmed.copy(rbfStatus = RbfStatus.InProgress(session)) Pair(nextState, listOf(ChannelAction.Message.Send(TxAckRbf(channelId, fundingParams.localContribution)))) } @@ -142,7 +142,7 @@ data class WaitForFundingConfirmed( Pair(this@WaitForFundingConfirmed.copy(rbfStatus = RbfStatus.RbfAborted), listOf(ChannelAction.Message.Send(TxAbort(channelId, ChannelFundingError(channelId).message)))) } is Either.Right -> { - val (session, action) = InteractiveTxSession(channelKeys(), keyManager.swapInOnChainWallet, fundingParams, 0.msat, 0.msat, contributions.value, previousFundingTxs.map { it.sharedTx }).send() + val (session, action) = InteractiveTxSession(channelKeys(), keyManager.swapInOnChainWallet, fundingParams, 0.msat, 0.msat, emptySet(), contributions.value, previousFundingTxs.map { it.sharedTx }).send() when (action) { is InteractiveTxSessionAction.SendMessage -> { val nextState = this@WaitForFundingConfirmed.copy(rbfStatus = RbfStatus.InProgress(session)) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForOpenChannel.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForOpenChannel.kt index 62c47578e..d9a3d6e42 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForOpenChannel.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForOpenChannel.kt @@ -86,7 +86,7 @@ data class WaitForOpenChannel( Pair(Aborted, listOf(ChannelAction.Message.Send(Error(temporaryChannelId, ChannelFundingError(temporaryChannelId).message)))) } is Either.Right -> { - val interactiveTxSession = InteractiveTxSession(channelKeys, keyManager.swapInOnChainWallet, fundingParams, 0.msat, 0.msat, fundingContributions.value) + val interactiveTxSession = InteractiveTxSession(channelKeys, keyManager.swapInOnChainWallet, fundingParams, 0.msat, 0.msat, emptySet(), fundingContributions.value) val nextState = WaitForFundingCreated( localParams, remoteParams, diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt index 0023df36f..99d371613 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt @@ -53,6 +53,7 @@ import fr.acinq.lightning.crypto.ShaChain import fr.acinq.lightning.transactions.Transactions import fr.acinq.lightning.utils.Either import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.msat import fr.acinq.lightning.wire.* import kotlinx.serialization.* import kotlinx.serialization.descriptors.SerialDescriptor @@ -402,7 +403,7 @@ internal data class Commitments( // We will put a WatchConfirmed when starting, which will return the confirmed transaction. fr.acinq.lightning.channel.LocalFundingStatus.UnconfirmedFundingTx( fr.acinq.lightning.channel.PartiallySignedSharedTransaction( - fr.acinq.lightning.channel.SharedTransaction(null, InteractiveTxOutput.Shared(0, commitInput.txOut.publicKeyScript, localCommit.spec.toLocal, localCommit.spec.toRemote), listOf(), listOf(), listOf(), listOf(), 0), + fr.acinq.lightning.channel.SharedTransaction(null, InteractiveTxOutput.Shared(0, commitInput.txOut.publicKeyScript, localCommit.spec.toLocal, localCommit.spec.toRemote, 0.msat), listOf(), listOf(), listOf(), listOf(), 0), // We must correctly set the txId here. TxSignatures(channelId, commitInput.outPoint.hash, listOf()), ), diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/ChannelState.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/ChannelState.kt index 3955a972a..bbe4bff60 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/ChannelState.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/ChannelState.kt @@ -53,6 +53,7 @@ import fr.acinq.lightning.crypto.ShaChain import fr.acinq.lightning.transactions.Transactions import fr.acinq.lightning.utils.Either import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.msat import fr.acinq.lightning.wire.* import kotlinx.serialization.* import kotlinx.serialization.descriptors.SerialDescriptor @@ -395,7 +396,7 @@ internal data class Commitments( // We will put a WatchConfirmed when starting, which will return the confirmed transaction. fr.acinq.lightning.channel.LocalFundingStatus.UnconfirmedFundingTx( fr.acinq.lightning.channel.PartiallySignedSharedTransaction( - fr.acinq.lightning.channel.SharedTransaction(null, InteractiveTxOutput.Shared(0, commitInput.txOut.publicKeyScript, localCommit.spec.toLocal, localCommit.spec.toRemote), listOf(), listOf(), listOf(), listOf(), 0), + fr.acinq.lightning.channel.SharedTransaction(null, InteractiveTxOutput.Shared(0, commitInput.txOut.publicKeyScript, localCommit.spec.toLocal, localCommit.spec.toRemote, 0.msat), listOf(), listOf(), listOf(), listOf(), 0), // We must correctly set the txId here. TxSignatures(channelId, commitInput.outPoint.hash, listOf()), ), diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt index 0edf0c81d..51ddd4365 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt @@ -182,6 +182,7 @@ object Deserialization { info = readInputInfo(), fundingTxIndex = readNumber(), remoteFundingPubkey = readPublicKey(), + localHtlcs = readCollection { readDirectedHtlc() }.toSet() ) else -> error("unknown discriminator $discriminator for class ${SharedFundingInput::class}") } @@ -206,6 +207,7 @@ object Deserialization { sequence = readNumber().toUInt(), localAmount = readNumber().msat, remoteAmount = readNumber().msat, + localHtlcs = readCollection { readDirectedHtlc() }.toSet() ) else -> error("unknown discriminator $discriminator for class ${InteractiveTxInput.Shared::class}") } @@ -254,6 +256,7 @@ object Deserialization { pubkeyScript = readDelimitedByteArray().toByteVector(), localAmount = readNumber().msat, remoteAmount = readNumber().msat, + htlcAmount = readNumber().msat ) else -> error("unknown discriminator $discriminator for class ${InteractiveTxOutput.Shared::class}") } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt index f3948de37..f817f529b 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt @@ -234,6 +234,7 @@ object Serialization { writeInputInfo(i.info) writeNumber(i.fundingTxIndex) writePublicKey(i.remoteFundingPubkey) + writeCollection(i.localHtlcs) { writeDirectedHtlc(it) } } } @@ -257,6 +258,7 @@ object Serialization { writeNumber(sequence.toLong()) writeNumber(localAmount.toLong()) writeNumber(remoteAmount.toLong()) + writeCollection(localHtlcs) { writeDirectedHtlc(it) } } private fun Output.writeLocalInteractiveTxInput(i: InteractiveTxInput.Local) = when (i) { @@ -305,6 +307,7 @@ object Serialization { writeDelimited(pubkeyScript.toByteArray()) writeNumber(localAmount.toLong()) writeNumber(remoteAmount.toLong()) + writeNumber(htlcAmount.toLong()) } private fun Output.writeLocalInteractiveTxOutput(o: InteractiveTxOutput.Local) = when (o) { diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt index 345f379d5..7dec76ff3 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/InteractiveTxTestsCommon.kt @@ -32,8 +32,8 @@ class InteractiveTxTestsCommon : LightningTestSuite() { assertEquals(f.fundingParamsA.fundingAmount, fundingA + fundingB) assertEquals(f.fundingParamsA.fundingAmount, fundingA + fundingB) - val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, f.fundingContributionsA) - val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, f.fundingContributionsB) + val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, emptySet(), f.fundingContributionsA) + val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, emptySet(), f.fundingContributionsB) // Alice --- tx_add_input --> Bob val (alice1, inputA1) = sendMessage(alice0) assertEquals(0xfffffffdU, inputA1.sequence) @@ -116,8 +116,8 @@ class InteractiveTxTestsCommon : LightningTestSuite() { val f = createFixture(fundingA, utxosA, fundingB, utxosB, targetFeerate, 660.sat, 0) assertEquals(f.fundingParamsA.fundingAmount, fundingA + fundingB) - val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, f.fundingContributionsA) - val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, f.fundingContributionsB) + val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, emptySet(), f.fundingContributionsA) + val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, emptySet(), 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) @@ -177,8 +177,8 @@ class InteractiveTxTestsCommon : LightningTestSuite() { val f = createFixture(fundingA, utxosA, fundingB, utxosB, targetFeerate, 660.sat, 0) assertEquals(f.fundingParamsA.fundingAmount, fundingA + fundingB) - val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, f.fundingContributionsA) - val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, f.fundingContributionsB) + val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, emptySet(), f.fundingContributionsA) + val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, emptySet(), f.fundingContributionsB) // Alice --- tx_add_input --> Bob val (alice1, inputA) = sendMessage(alice0) // Alice <-- tx_add_input --- Bob @@ -227,8 +227,8 @@ class InteractiveTxTestsCommon : LightningTestSuite() { val f = createFixture(fundingA, utxosA, 0.sat, listOf(), targetFeerate, 330.sat, 0) assertEquals(f.fundingParamsA.fundingAmount, fundingA) - val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, f.fundingContributionsA) - val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, f.fundingContributionsB) + val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, emptySet(), f.fundingContributionsA) + val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, emptySet(), f.fundingContributionsB) // Alice --- tx_add_input --> Bob val (alice1, inputA1) = sendMessage(alice0) // Alice <-- tx_complete --- Bob @@ -298,8 +298,8 @@ class InteractiveTxTestsCommon : LightningTestSuite() { assertNotNull(f.fundingParamsA.sharedInput) assertNotNull(f.fundingParamsB.sharedInput) - val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, balanceA, balanceB, f.fundingContributionsA) - val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, balanceB, balanceA, f.fundingContributionsB) + val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, balanceA, balanceB, emptySet(), f.fundingContributionsA) + val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, balanceB, balanceA, emptySet(), f.fundingContributionsB) // Alice --- tx_add_input --> Bob val (alice1, inputA1) = sendMessage(alice0) // Alice <-- tx_add_input --- Bob @@ -375,8 +375,8 @@ class InteractiveTxTestsCommon : LightningTestSuite() { assertNotNull(f.fundingParamsA.sharedInput) assertNotNull(f.fundingParamsB.sharedInput) - val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, balanceA, balanceB, f.fundingContributionsA) - val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, balanceB, balanceA, f.fundingContributionsB) + val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, balanceA, balanceB, emptySet(), f.fundingContributionsA) + val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, balanceB, balanceA, emptySet(), f.fundingContributionsB) // Alice --- tx_add_input --> Bob val (alice1, inputA) = sendMessage(alice0) // Alice <-- tx_add_output --- Bob @@ -448,8 +448,8 @@ class InteractiveTxTestsCommon : LightningTestSuite() { assertNotNull(f.fundingParamsA.sharedInput) assertNotNull(f.fundingParamsB.sharedInput) - val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, balanceA, balanceB, f.fundingContributionsA) - val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, balanceB, balanceA, f.fundingContributionsB) + val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, balanceA, balanceB, emptySet(), f.fundingContributionsA) + val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, balanceB, balanceA, emptySet(), f.fundingContributionsB) // Alice --- tx_add_input --> Bob val (alice1, inputA) = sendMessage(alice0) // Alice <-- tx_add_output --- Bob @@ -526,8 +526,8 @@ class InteractiveTxTestsCommon : LightningTestSuite() { assertNotNull(f.fundingParamsA.sharedInput) assertNotNull(f.fundingParamsB.sharedInput) - val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, balanceA, balanceB, f.fundingContributionsA) - val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, balanceB, balanceA, f.fundingContributionsB) + val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, balanceA, balanceB, emptySet(), f.fundingContributionsA) + val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, balanceB, balanceA, emptySet(), f.fundingContributionsB) // Alice --- tx_add_input --> Bob val (alice1, inputA1) = sendMessage(alice0) // Alice <-- tx_add_input --- Bob @@ -595,7 +595,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { fun `remove input - output`() { val f = createFixture(100_000.sat, listOf(150_000.sat), 0.sat, listOf(), FeeratePerKw(2500.sat), 330.sat, 0) // In this flow we introduce dummy inputs/outputs from Bob to Alice that are then removed. - val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, f.fundingContributionsA) + val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, emptySet(), f.fundingContributionsA) // Alice --- tx_add_input --> Bob val (alice1, inputA) = sendMessage(alice0) // Alice <-- tx_add_input --- Bob @@ -682,7 +682,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { TxAddInput(f.channelId, 9, previousTx, 2, 0xffffffffU) to InteractiveTxSessionAction.NonReplaceableInput(f.channelId, 9, previousTx.txid, 2, 0xffffffff), ) testCases.forEach { (input, expected) -> - val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, f.fundingContributionsA) + val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, emptySet(), f.fundingContributionsA) // Alice --- tx_add_input --> Bob val (alice1, _) = sendMessage(alice0) // Alice <-- tx_add_input --- Bob @@ -702,7 +702,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { TxAddOutput(f.channelId, 1, 25_000.sat, Script.write(listOf(OP_1)).byteVector()), ) testCases.forEach { output -> - val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, f.fundingContributionsA) + val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, emptySet(), f.fundingContributionsA) // Alice --- tx_add_input --> Bob val (alice1, _) = sendMessage(alice0) // Alice <-- tx_add_output --- Bob @@ -722,7 +722,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { TxAddOutput(f.channelId, 3, 329.sat, validScript) to InteractiveTxSessionAction.OutputBelowDust(f.channelId, 3, 329.sat, 330.sat), ) testCases.forEach { (output, expected) -> - val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, f.fundingContributionsA) + val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, emptySet(), f.fundingContributionsA) // Alice --- tx_add_input --> Bob val (alice1, _) = sendMessage(alice0) // Alice <-- tx_add_output --- Bob @@ -743,7 +743,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { TxRemoveInput(f.channelId, 57) to InteractiveTxSessionAction.UnknownSerialId(f.channelId, 57), ) testCases.forEach { (msg, expected) -> - val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, f.fundingContributionsA) + val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, emptySet(), f.fundingContributionsA) // Alice --- tx_add_input --> Bob val (alice1, _) = sendMessage(alice0) // Alice <-- tx_remove_(in|out)put --- Bob @@ -756,7 +756,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { fun `too many protocol rounds`() { val f = createFixture(100_000.sat, listOf(120_000.sat), 0.sat, listOf(), FeeratePerKw(5000.sat), 330.sat, 0) val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector() - var (alice, _) = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, f.fundingContributionsA).send() + var (alice, _) = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, emptySet(), 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)) @@ -769,7 +769,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { @Test fun `too many inputs`() { val f = createFixture(100_000.sat, listOf(120_000.sat), 0.sat, listOf(), FeeratePerKw(5000.sat), 330.sat, 0) - var (alice, _) = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, f.fundingContributionsA).send() + var (alice, _) = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, emptySet(), f.fundingContributionsA).send() (1..252).forEach { i -> // Alice --- tx_message --> Bob val (alice1, _) = alice.receive(createTxAddInput(f.channelId, 2 * i.toLong() + 1, 5000.sat)) @@ -785,7 +785,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { @Test fun `too many outputs`() { val f = createFixture(100_000.sat, listOf(120_000.sat), 0.sat, listOf(), FeeratePerKw(5000.sat), 330.sat, 0) - var (alice, _) = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, f.fundingContributionsA).send() + var (alice, _) = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, emptySet(), f.fundingContributionsA).send() val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector() (1..252).forEach { i -> // Alice --- tx_message --> Bob @@ -804,7 +804,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { fun `missing funding output`() { val f = createFixture(100_000.sat, listOf(120_000.sat), 0.sat, listOf(), FeeratePerKw(5000.sat), 330.sat, 0) val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector() - val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, f.fundingContributionsB) + val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, emptySet(), f.fundingContributionsB) // Alice --- tx_add_input --> Bob val (bob1, _) = receiveMessage(bob0, createTxAddInput(f.channelId, 0, 150_000.sat)) // Alice --- tx_add_output --> Bob @@ -817,7 +817,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { @Test fun `multiple funding outputs`() { val f = createFixture(100_000.sat, listOf(120_000.sat), 0.sat, listOf(), FeeratePerKw(5000.sat), 330.sat, 0) - val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, f.fundingContributionsB) + val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, emptySet(), f.fundingContributionsB) // Alice --- tx_add_input --> Bob val (bob1, _) = receiveMessage(bob0, createTxAddInput(f.channelId, 0, 150_000.sat)) // Alice --- tx_add_output --> Bob @@ -835,7 +835,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { val spliceOutputA = TxOut(20_000.sat, Script.pay2wpkh(randomKey().publicKey())) val subtractedFundingA = 25_000.sat val f = createSpliceFixture(balanceA, -subtractedFundingA, listOf(), listOf(spliceOutputA), 0.msat, 0.sat, listOf(), listOf(), FeeratePerKw(5000.sat), 330.sat, 0) - val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, balanceA, f.fundingContributionsB) + val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, balanceA, emptySet(), f.fundingContributionsB) // Alice --- tx_add_output --> Bob val (bob1, _) = receiveMessage(bob0, TxAddOutput(f.channelId, 0, 75_000.sat, f.fundingParamsB.fundingPubkeyScript(f.channelKeysB))) // Alice --- tx_add_output --> Bob @@ -848,8 +848,8 @@ class InteractiveTxTestsCommon : LightningTestSuite() { @Test fun `swap-in input missing user key`() { val f = createFixture(100_000.sat, listOf(150_000.sat), 0.sat, listOf(), FeeratePerKw(2500.sat), 330.sat, 0) - val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, f.fundingContributionsA) - val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, f.fundingContributionsB) + val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, 0.msat, 0.msat, emptySet(), f.fundingContributionsA) + val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, emptySet(), f.fundingContributionsB) // Alice --- tx_add_input --> Bob val (alice1, inputA) = sendMessage(alice0) // Alice <-- tx_complete --- Bob @@ -878,7 +878,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { @Test fun `invalid funding amount`() { val f = createFixture(100_000.sat, listOf(120_000.sat), 0.sat, listOf(), FeeratePerKw(5000.sat), 330.sat, 0) - val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, f.fundingContributionsB) + val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, emptySet(), f.fundingContributionsB) // Alice --- tx_add_input --> Bob val (bob1, _) = receiveMessage(bob0, createTxAddInput(f.channelId, 0, 150_000.sat)) // Alice --- tx_add_output --> Bob @@ -899,8 +899,8 @@ class InteractiveTxTestsCommon : LightningTestSuite() { assertNotNull(f.fundingParamsA.sharedInput) assertNotNull(f.fundingParamsB.sharedInput) - val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, balanceA, balanceB, f.fundingContributionsA) - val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, balanceB, balanceA, f.fundingContributionsB) + val alice0 = InteractiveTxSession(f.channelKeysA, f.keyManagerA.swapInOnChainWallet, f.fundingParamsA, balanceA, balanceB, emptySet(), f.fundingContributionsA) + val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, balanceB, balanceA, emptySet(), f.fundingContributionsB) // Alice --- tx_add_input --> Bob val (alice1, inputA) = sendMessage(alice0) // Alice <-- tx_complete --- Bob @@ -939,7 +939,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { @Test fun `missing previous tx`() { val f = createFixture(100_000.sat, listOf(120_000.sat), 0.sat, listOf(), FeeratePerKw(5000.sat), 330.sat, 0) - val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, f.fundingContributionsB) + val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, emptySet(), f.fundingContributionsB) // Alice --- tx_add_output --> Bob val failure = receiveInvalidMessage(bob0, TxAddInput(f.channelId, 0, null, 3, 0u)) assertIs(failure) @@ -948,7 +948,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { @Test fun `total input amount too low`() { val f = createFixture(100_000.sat, listOf(120_000.sat), 0.sat, listOf(), FeeratePerKw(5000.sat), 330.sat, 0) - val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, f.fundingContributionsB) + val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, emptySet(), f.fundingContributionsB) val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector() // Alice --- tx_add_input --> Bob val (bob1, _) = receiveMessage(bob0, createTxAddInput(f.channelId, 0, 150_000.sat)) @@ -964,7 +964,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { @Test fun `minimum fee not met`() { val f = createFixture(100_000.sat, listOf(120_000.sat), 0.sat, listOf(), FeeratePerKw(5000.sat), 330.sat, 0) - val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, f.fundingContributionsB) + val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, emptySet(), f.fundingContributionsB) val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector() // Alice --- tx_add_input --> Bob val (bob1, _) = receiveMessage(bob0, createTxAddInput(f.channelId, 0, 150_000.sat)) @@ -981,7 +981,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { @Test fun `previous attempts not double-spent`() { val f = createFixture(100_000.sat, listOf(120_000.sat), 0.sat, listOf(), FeeratePerKw(5000.sat), 330.sat, 0) - val sharedOutput = InteractiveTxOutput.Shared(0, f.fundingParamsA.fundingPubkeyScript(f.channelKeysA), 100_000_000.msat, 0.msat) + val sharedOutput = InteractiveTxOutput.Shared(0, f.fundingParamsA.fundingPubkeyScript(f.channelKeysA), 100_000_000.msat, 0.msat, 0.msat) 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() @@ -995,7 +995,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { SharedTransaction(null, sharedOutput, listOf(), firstAttempt.tx.remoteInputs + listOf(InteractiveTxInput.RemoteOnly(4, OutPoint(previousTx2, 1), TxOut(150_000.sat, validScript), 0u)), listOf(), listOf(), 0), TxSignatures(f.channelId, randomBytes32(), listOf()), ) - val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, f.fundingContributionsB, listOf(firstAttempt, secondAttempt)) + val bob0 = InteractiveTxSession(f.channelKeysB, f.keyManagerB.swapInOnChainWallet, f.fundingParamsB, 0.msat, 0.msat, emptySet(), f.fundingContributionsB, listOf(firstAttempt, secondAttempt)) // Alice --- tx_add_input --> Bob val (bob1, _) = receiveMessage(bob0, TxAddInput(f.channelId, 4, previousTx2, 1, 0u)) // Alice --- tx_add_output --> Bob @@ -1013,7 +1013,7 @@ class InteractiveTxTestsCommon : LightningTestSuite() { val parentTx = Transaction.read( "02000000000101f86fd1d0db3ac5a72df968622f31e6b5e6566a09e29206d7c7a55df90e181de800000000171600141fb9623ffd0d422eacc450fd1e967efc477b83ccffffffff0580b2e60e00000000220020fd89acf65485df89797d9ba7ba7a33624ac4452f00db08107f34257d33e5b94680b2e60e0000000017a9146a235d064786b49e7043e4a042d4cc429f7eb6948780b2e60e00000000160014fbb4db9d85fba5e301f4399e3038928e44e37d3280b2e60e0000000017a9147ecd1b519326bc13b0ec716e469b58ed02b112a087f0006bee0000000017a914f856a70093da3a5b5c4302ade033d4c2171705d387024730440220696f6cee2929f1feb3fd6adf024ca0f9aa2f4920ed6d35fb9ec5b78c8408475302201641afae11242160101c6f9932aeb4fcd1f13a9c6df5d1386def000ea259a35001210381d7d5b1bc0d7600565d827242576d9cb793bfe0754334af82289ee8b65d137600000000" ) - val sharedOutput = InteractiveTxOutput.Shared(44, ByteVector("0020297b92c238163e820b82486084634b4846b86a3c658d87b9384192e6bea98ec5"), 200_000_000_000.msat, 200_000_000_000.msat) + val sharedOutput = InteractiveTxOutput.Shared(44, ByteVector("0020297b92c238163e820b82486084634b4846b86a3c658d87b9384192e6bea98ec5"), 200_000_000_000.msat, 200_000_000_000.msat, 0.msat) val initiatorTx = run { val initiatorInput = InteractiveTxInput.LocalOnly(20, parentTx, 0, 4294967293u) @@ -1137,10 +1137,10 @@ class InteractiveTxTestsCommon : LightningTestSuite() { val previousFundingAmount = (balanceA + balanceB).truncateToSatoshi() val previousFundingTx = Transaction(2, listOf(TxIn(OutPoint(randomBytes32(), 0), 0)), listOf(TxOut(previousFundingAmount, fundingScript)), 0) val inputInfo = Transactions.InputInfo(OutPoint(previousFundingTx, 0), previousFundingTx.txOut[0], redeemScript) - val sharedInputA = SharedFundingInput.Multisig2of2(inputInfo, fundingTxIndex, channelKeysB.fundingPubKey(fundingTxIndex)) + val sharedInputA = SharedFundingInput.Multisig2of2(inputInfo, fundingTxIndex, channelKeysB.fundingPubKey(fundingTxIndex), emptySet()) val nextFundingPubkeyB = channelKeysB.fundingPubKey(fundingTxIndex + 1) val fundingParamsA = InteractiveTxParams(channelId, true, fundingContributionA, fundingContributionB, sharedInputA, nextFundingPubkeyB, outputsA, lockTime, dustLimit, targetFeerate) - return FundingContributions.create(channelKeysA, swapInKeysA, fundingParamsA, Pair(sharedInputA, SharedFundingInputBalances(balanceA, balanceB)), listOf(), outputsA, randomKey().publicKey()) + return FundingContributions.create(channelKeysA, swapInKeysA, fundingParamsA, Pair(sharedInputA, SharedFundingInputBalances(balanceA, balanceB, emptySet())), listOf(), outputsA, randomKey().publicKey()) } private fun createSpliceFixture( @@ -1171,17 +1171,17 @@ class InteractiveTxTestsCommon : LightningTestSuite() { val previousFundingAmount = (balanceA + balanceB).truncateToSatoshi() val previousFundingTx = Transaction(2, listOf(TxIn(OutPoint(randomBytes32(), 0), 0)), listOf(TxOut(previousFundingAmount, fundingScript)), 0) val inputInfo = Transactions.InputInfo(OutPoint(previousFundingTx, 0), previousFundingTx.txOut[0], redeemScript) - val sharedInputA = SharedFundingInput.Multisig2of2(inputInfo, fundingTxIndex, channelKeysB.fundingPubKey(fundingTxIndex)) - val sharedInputB = SharedFundingInput.Multisig2of2(inputInfo, fundingTxIndex, channelKeysA.fundingPubKey(fundingTxIndex)) + val sharedInputA = SharedFundingInput.Multisig2of2(inputInfo, fundingTxIndex, channelKeysB.fundingPubKey(fundingTxIndex), emptySet()) + val sharedInputB = SharedFundingInput.Multisig2of2(inputInfo, fundingTxIndex, channelKeysA.fundingPubKey(fundingTxIndex), emptySet()) val nextFundingPubkeyA = channelKeysA.fundingPubKey(fundingTxIndex + 1) val nextFundingPubkeyB = channelKeysB.fundingPubKey(fundingTxIndex + 1) val fundingParamsA = InteractiveTxParams(channelId, true, fundingContributionA, fundingContributionB, sharedInputA, nextFundingPubkeyB, outputsA, lockTime, dustLimit, targetFeerate) val fundingParamsB = InteractiveTxParams(channelId, false, fundingContributionB, fundingContributionA, sharedInputB, nextFundingPubkeyA, outputsB, lockTime, dustLimit, targetFeerate) val walletA = createWallet(swapInKeysA, utxosA) - val contributionsA = FundingContributions.create(channelKeysA, swapInKeysA, fundingParamsA, Pair(sharedInputA, SharedFundingInputBalances(balanceA, balanceB)), walletA, outputsA, randomKey().publicKey()) + val contributionsA = FundingContributions.create(channelKeysA, swapInKeysA, fundingParamsA, Pair(sharedInputA, SharedFundingInputBalances(balanceA, balanceB, emptySet())), walletA, outputsA, randomKey().publicKey()) assertNotNull(contributionsA.right) val walletB = createWallet(swapInKeysB, utxosB) - val contributionsB = FundingContributions.create(channelKeysB, swapInKeysB, fundingParamsB, Pair(sharedInputB, SharedFundingInputBalances(balanceB, balanceA)), walletB, outputsB, randomKey().publicKey()) + val contributionsB = FundingContributions.create(channelKeysB, swapInKeysB, fundingParamsB, Pair(sharedInputB, SharedFundingInputBalances(balanceB, balanceA, emptySet())), walletB, outputsB, randomKey().publicKey()) assertNotNull(contributionsB.right) return Fixture(channelId, TestConstants.Alice.keyManager, channelKeysA, localParamsA, fundingParamsA, contributionsA.right!!, TestConstants.Bob.keyManager, channelKeysB, localParamsB, fundingParamsB, contributionsB.right!!) } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt index b017ccdfb..5db8685a3 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt @@ -3,6 +3,7 @@ package fr.acinq.lightning.channel.states import fr.acinq.bitcoin.* import fr.acinq.lightning.Lightning import fr.acinq.lightning.Lightning.randomKey +import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.blockchain.* import fr.acinq.lightning.blockchain.electrum.WalletState import fr.acinq.lightning.blockchain.fee.FeeratePerKw @@ -14,6 +15,9 @@ import fr.acinq.lightning.channel.TestsHelper.reachNormal import fr.acinq.lightning.channel.TestsHelper.useAlternativeCommitSig import fr.acinq.lightning.crypto.KeyManager import fr.acinq.lightning.tests.utils.LightningTestSuite +import fr.acinq.lightning.transactions.Transactions +import fr.acinq.lightning.transactions.incomings +import fr.acinq.lightning.transactions.outgoings import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat import fr.acinq.lightning.utils.sum @@ -21,6 +25,7 @@ import fr.acinq.lightning.utils.toMilliSatoshi import fr.acinq.lightning.wire.* import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.runBlocking +import kotlin.math.abs import kotlin.test.* class SpliceTestsCommon : LightningTestSuite() { @@ -28,13 +33,25 @@ class SpliceTestsCommon : LightningTestSuite() { @Test fun `splice funds out`() { val (alice, bob) = reachNormal() - spliceOut(alice, bob, 50_000.sat) + val (nodes, preimage, _) = addHtlc(15_000_000.msat, alice, bob) + val (alice1, bob1) = crossSign(nodes.first, nodes.second) + val (alice2, bob2) = spliceOut(alice1, bob1, 50_000.sat) + val fee = spliceFee(alice2, capacity = 950_000.sat) + fulfillHtlc(0, preimage, alice2, bob2) + assertEquals(alice2.state.commitments.latest.localCommit.spec.toLocal, 750_000_000.msat - fee.toMilliSatoshi() - 15_000_000.msat) + assertEquals(alice2.state.commitments.latest.localCommit.spec.toRemote, 200_000_000.msat) } @Test fun `splice funds in`() { val (alice, bob) = reachNormal() - spliceIn(alice, bob, listOf(50_000.sat)) + val (nodes, preimage, _) = addHtlc(15_000_000.msat, alice, bob) + val (alice1, bob1) = crossSign(nodes.first, nodes.second) + val (alice2, bob2) = spliceIn(alice1, bob1, listOf(50_000.sat)) + fulfillHtlc(0, preimage, alice2, bob2) + val fee = spliceFee(alice2, capacity = 1_050_000.sat) + assertEquals(alice2.state.commitments.latest.localCommit.spec.toLocal, 850_000_000.msat - fee.toMilliSatoshi() - 15_000_000.msat) + assertEquals(alice2.state.commitments.latest.localCommit.spec.toRemote, 200_000_000.msat) } @Test @@ -82,30 +99,37 @@ class SpliceTestsCommon : LightningTestSuite() { assertEquals(1, bob7.commitments.remoteCommitIndex) Pair(Pair(alice7, bob7), Pair(preimage1, preimage2)) } - - // TODO: once we support quiescence, fulfill those HTLCs after the splice instead of before. - val (alice1, bob1) = fulfillHtlc(0, preimages.first, nodes.first, nodes.second) - val (bob2, alice2) = fulfillHtlc(0, preimages.second, bob1, alice1) - val (alice3, bob3) = crossSign(alice2, bob2) - assertEquals(2, alice3.commitments.localCommitIndex) - assertEquals(4, alice3.commitments.remoteCommitIndex) - - spliceIn(alice3, bob3, listOf(500_000.sat)) + val (alice1, bob1) = spliceIn(nodes.first, nodes.second, listOf(500_000.sat)) + val (alice2, bob2) = fulfillHtlc(0, preimages.first, alice1, bob1) + val (bob3, alice3) = fulfillHtlc(0, preimages.second, bob2, alice2) + val (alice4, _) = crossSign(alice3, bob3, commitmentsCount = 2) + assertEquals(2, alice4.commitments.localCommitIndex) + assertEquals(4, alice4.commitments.remoteCommitIndex) } @Test fun `splice cpfp`() { val (alice, bob) = reachNormal() - spliceIn(alice, bob, listOf(50_000.sat)) - spliceOut(alice, bob, 50_000.sat) - spliceCpfp(alice, bob) + val (nodes, preimage, _) = addHtlc(15_000_000.msat, alice, bob) + val (alice0, bob0) = crossSign(nodes.first, nodes.second) + val (alice1, bob1) = spliceIn(alice0, bob0, listOf(50_000.sat)) + val fee1 = spliceFee(alice1, capacity = 1_050_000.sat) + val (alice2, bob2) = spliceOut(alice1, bob1, 50_000.sat) + val fee2 = spliceFee(alice2, capacity = 1_000_000.sat - fee1) + val (alice3, bob3) = spliceCpfp(alice2, bob2) + val (alice4, _) = fulfillHtlc(0, preimage, alice3, bob3) + val fee3 = spliceFee(alice4, capacity = 1_000_000.sat - fee1 - fee2) + assertEquals(alice4.state.commitments.latest.localCommit.spec.toLocal, 800_000_000.msat - (fee1 + fee2 + fee3).toMilliSatoshi() - 15_000_000.msat) + assertEquals(alice4.state.commitments.latest.localCommit.spec.toRemote, 200_000_000.msat) } @Test fun `reject splice_init`() { val cmd = createSpliceOutRequest(25_000.sat) val (alice, bob) = reachNormal() - val (alice1, _, _) = reachQuiescent(cmd, alice, bob) + val (nodes, _, _) = addHtlc(15_000_000.msat, alice, bob) + val (alice0, bob0) = crossSign(nodes.first, nodes.second) + val (alice1, _, _) = reachQuiescent(cmd, alice0, bob0) val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(TxAbort(alice.channelId, "thanks but no thanks"))) assertIs(alice2.state) assertEquals(alice2.state.spliceStatus, SpliceStatus.None) @@ -117,21 +141,26 @@ class SpliceTestsCommon : LightningTestSuite() { fun `reject splice_ack`() { val cmd = createSpliceOutRequest(25_000.sat) val (alice, bob) = reachNormal() - val (_, bobQuiescent, spliceInit) = reachQuiescent(cmd, alice, bob) + val (nodes, _, _) = addHtlc(15_000_000.msat, alice, bob) + val (alice0, bob0) = crossSign(nodes.first, nodes.second) + val (_, bobQuiescent, spliceInit) = reachQuiescent(cmd, alice0, bob0) val (bob1, actionsBob1) = bobQuiescent.process(ChannelCommand.MessageReceived(spliceInit)) actionsBob1.hasOutgoingMessage() val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(TxAbort(alice.channelId, "changed my mind"))) assertIs(bob2.state) assertEquals(bob2.state.spliceStatus, SpliceStatus.None) - assertEquals(actionsBob2.size, 1) + assertEquals(actionsBob2.size, 2) actionsBob2.hasOutgoingMessage() + actionsBob2.has() } @Test fun `abort before tx_complete`() { val cmd = createSpliceOutRequest(20_000.sat) val (alice, bob) = reachNormal() - val (alice1, bobQuiescent, spliceInit) = reachQuiescent(cmd, alice, bob) + val (nodes, _, _) = addHtlc(15_000_000.msat, alice, bob) + val (alice0, bob0) = crossSign(nodes.first, nodes.second) + val (alice1, bobQuiescent, spliceInit) = reachQuiescent(cmd, alice0, bob0) val (bob1, actionsBob1) = bobQuiescent.process(ChannelCommand.MessageReceived(spliceInit)) val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(actionsBob1.findOutgoingMessage())) val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(actionsAlice2.findOutgoingMessage())) @@ -148,8 +177,9 @@ class SpliceTestsCommon : LightningTestSuite() { val (bob3, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(TxAbort(alice.channelId, "internal error"))) assertIs(bob3.state) assertEquals(bob3.state.spliceStatus, SpliceStatus.None) - assertEquals(actionsBob3.size, 1) + assertEquals(actionsBob3.size, 2) actionsBob3.hasOutgoingMessage() + actionsBob3.has() } } @@ -157,7 +187,9 @@ class SpliceTestsCommon : LightningTestSuite() { fun `abort after tx_complete`() { val cmd = createSpliceOutRequest(31_000.sat) val (alice, bob) = reachNormal() - val (alice1, bobQuiescent, spliceInit) = reachQuiescent(cmd, alice, bob) + val (nodes, _, _) = addHtlc(15_000_000.msat, alice, bob) + val (alice0, bob0) = crossSign(nodes.first, nodes.second) + val (alice1, bobQuiescent, spliceInit) = reachQuiescent(cmd, alice0, bob0) val (bob1, actionsBob1) = bobQuiescent.process(ChannelCommand.MessageReceived(spliceInit)) val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(actionsBob1.findOutgoingMessage())) val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(actionsAlice2.findOutgoingMessage())) @@ -181,9 +213,10 @@ class SpliceTestsCommon : LightningTestSuite() { val (bob6, actionsBob6) = bob5.process(ChannelCommand.MessageReceived(TxAbort(alice.channelId, "internal error"))) assertIs(bob6.state) assertEquals(bob6.state.spliceStatus, SpliceStatus.None) - assertEquals(actionsBob6.size, 2) + assertEquals(actionsBob6.size, 3) actionsBob6.hasOutgoingMessage() actionsBob6.has() + actionsBob6.has() } } @@ -191,7 +224,9 @@ class SpliceTestsCommon : LightningTestSuite() { fun `abort after tx_complete then receive commit_sig`() { val cmd = createSpliceOutRequest(50_000.sat) val (alice, bob) = reachNormal() - val (alice1, bobQuiescent, spliceInit) = reachQuiescent(cmd, alice, bob) + val (nodes, _, _) = addHtlc(15_000_000.msat, alice, bob) + val (alice0, bob0) = crossSign(nodes.first, nodes.second) + val (alice1, bobQuiescent, spliceInit) = reachQuiescent(cmd, alice0, bob0) val (bob1, actionsBob1) = bobQuiescent.process(ChannelCommand.MessageReceived(spliceInit)) val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(actionsBob1.findOutgoingMessage())) val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(actionsAlice2.findOutgoingMessage())) @@ -217,7 +252,8 @@ class SpliceTestsCommon : LightningTestSuite() { assertIs(bob7.state) assertEquals(1, bob7.commitments.active.size) assertEquals(SpliceStatus.None, bob7.state.spliceStatus) - assertTrue(actionsBob7.isEmpty()) + assertEquals(1, actionsBob7.size) + actionsBob7.has() } @Test @@ -409,7 +445,9 @@ class SpliceTestsCommon : LightningTestSuite() { @Test fun `disconnect -- commit_sig not received`() { val (alice, bob) = reachNormalWithConfirmedFundingTx() - val (alice1, _, bob1, _) = spliceOutWithoutSigs(alice, bob, 20_000.sat) + val (alice0, bob0, htlcs) = setupHtlcs(alice, bob) + val (alice1, _, bob1, _) = initiateSpliceWithoutSigs(alice0, bob0, inAmounts = listOf(50_000.sat), pushAmount = 0.msat, outAmount = 100_000.sat) + val spliceStatus = alice1.state.spliceStatus assertIs(spliceStatus) @@ -417,46 +455,57 @@ class SpliceTestsCommon : LightningTestSuite() { assertEquals(channelReestablishAlice.nextFundingTxId, spliceStatus.session.fundingTx.txId) val (bob3, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(channelReestablishAlice)) assertIs>(bob3) - assertEquals(actionsBob3.size, 2) + assertEquals(actionsBob3.size, 4) val channelReestablishBob = actionsBob3.findOutgoingMessage() val commitSigBob = actionsBob3.findOutgoingMessage() + assertEquals(htlcs.aliceToBob.map { it.second }.toSet(), actionsBob3.filterIsInstance().map { it.add }.toSet()) assertEquals(channelReestablishBob.nextFundingTxId, spliceStatus.session.fundingTx.txId) val (alice3, actionsAlice3) = alice2.process(ChannelCommand.MessageReceived(channelReestablishBob)) assertIs>(alice3) - assertEquals(actionsAlice3.size, 1) + assertEquals(actionsAlice3.size, 3) val commitSigAlice = actionsAlice3.findOutgoingMessage() - exchangeSpliceSigs(alice3, commitSigAlice, bob3, commitSigBob) + val (alice4, bob4) = exchangeSpliceSigs(alice3, commitSigAlice, bob3, commitSigBob) + assertEquals(htlcs.bobToAlice.map { it.second }.toSet(), actionsAlice3.filterIsInstance().map { it.add }.toSet()) + + val fee = spliceFee(alice4, 950_000.sat) + resolveHtlcs(alice4, bob4, htlcs, spliceOutFee = fee, commitmentsCount = 2) } @Test fun `disconnect -- commit_sig received by alice`() { val (alice, bob) = reachNormalWithConfirmedFundingTx() - val (alice1, _, bob1, commitSigBob1) = spliceOutWithoutSigs(alice, bob, 20_000.sat) - val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(commitSigBob1)) - assertIs>(alice2) - assertTrue(actionsAlice2.isEmpty()) - val spliceStatus = alice2.state.spliceStatus + val (alice1, bob1, htlcs) = setupHtlcs(alice, bob) + val (alice2, _, bob2, commitSigBob1) = initiateSpliceWithoutSigs(alice1, bob1, inAmounts = listOf(50_000.sat), pushAmount = 0.msat, outAmount = 100_000.sat) + val (alice3, actionsAlice3) = alice2.process(ChannelCommand.MessageReceived(commitSigBob1)) + assertIs>(alice3) + assertTrue(actionsAlice3.isEmpty()) + val spliceStatus = alice3.state.spliceStatus assertIs(spliceStatus) - val (alice3, bob2, channelReestablishAlice) = disconnect(alice2, bob1) + val (alice4, bob3, channelReestablishAlice) = disconnect(alice3, bob2) assertEquals(channelReestablishAlice.nextFundingTxId, spliceStatus.session.fundingTx.txId) - val (bob3, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(channelReestablishAlice)) - assertIs>(bob3) - assertEquals(actionsBob3.size, 2) - val channelReestablishBob = actionsBob3.findOutgoingMessage() - val commitSigBob2 = actionsBob3.findOutgoingMessage() + val (bob4, actionsBob4) = bob3.process(ChannelCommand.MessageReceived(channelReestablishAlice)) + assertIs>(bob4) + assertEquals(actionsBob4.size, 4) + val channelReestablishBob = actionsBob4.findOutgoingMessage() + val commitSigBob2 = actionsBob4.findOutgoingMessage() + assertEquals(htlcs.aliceToBob.map { it.second }.toSet(), actionsBob4.filterIsInstance().map { it.add }.toSet()) assertEquals(channelReestablishBob.nextFundingTxId, spliceStatus.session.fundingTx.txId) - val (alice4, actionsAlice4) = alice3.process(ChannelCommand.MessageReceived(channelReestablishBob)) - assertIs>(alice4) - assertEquals(actionsAlice4.size, 1) - val commitSigAlice = actionsAlice4.findOutgoingMessage() - exchangeSpliceSigs(alice4, commitSigAlice, bob3, commitSigBob2) + val (alice5, actionsAlice5) = alice4.process(ChannelCommand.MessageReceived(channelReestablishBob)) + assertIs>(alice5) + assertEquals(actionsAlice5.size, 3) + val commitSigAlice = actionsAlice5.findOutgoingMessage() + assertEquals(htlcs.bobToAlice.map { it.second }.toSet(), actionsAlice5.filterIsInstance().map { it.add }.toSet()) + val (alice6, bob5) = exchangeSpliceSigs(alice5, commitSigAlice, bob4, commitSigBob2) + val fee = spliceFee(alice6, 950_000.sat) + resolveHtlcs(alice6, bob5, htlcs, spliceOutFee = fee, commitmentsCount = 2) } @Test fun `disconnect -- tx_signatures sent by bob`() { val (alice, bob) = reachNormalWithConfirmedFundingTx() - val (alice1, commitSigAlice1, bob1, _) = spliceOutWithoutSigs(alice, bob, 20_000.sat) + val (alice0, bob0, htlcs) = setupHtlcs(alice, bob) + val (alice1, commitSigAlice1, bob1, _) = initiateSpliceWithoutSigs(alice0, bob0, inAmounts = listOf(50_000.sat), pushAmount = 0.msat, outAmount = 100_000.sat) val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(commitSigAlice1)) assertIs>(bob2) val spliceTxId = actionsBob2.hasOutgoingMessage().txId @@ -465,13 +514,15 @@ class SpliceTestsCommon : LightningTestSuite() { val (alice2, bob3, channelReestablishAlice) = disconnect(alice1, bob2) assertEquals(channelReestablishAlice.nextFundingTxId, spliceTxId) val (bob4, actionsBob4) = bob3.process(ChannelCommand.MessageReceived(channelReestablishAlice)) - assertEquals(actionsBob4.size, 3) + assertEquals(actionsBob4.size, 5) val channelReestablishBob = actionsBob4.findOutgoingMessage() val commitSigBob2 = actionsBob4.findOutgoingMessage() + assertEquals(htlcs.aliceToBob.map { it.second }.toSet(), actionsBob4.filterIsInstance().map { it.add }.toSet()) val txSigsBob = actionsBob4.findOutgoingMessage() assertEquals(channelReestablishBob.nextFundingTxId, spliceTxId) val (alice3, actionsAlice3) = alice2.process(ChannelCommand.MessageReceived(channelReestablishBob)) - assertEquals(actionsAlice3.size, 1) + assertEquals(actionsAlice3.size, 3) + assertEquals(htlcs.bobToAlice.map { it.second }.toSet(), actionsAlice3.filterIsInstance().map { it.add }.toSet()) val commitSigAlice2 = actionsAlice3.findOutgoingMessage() val (alice4, actionsAlice4) = alice3.process(ChannelCommand.MessageReceived(commitSigBob2)) @@ -479,8 +530,9 @@ class SpliceTestsCommon : LightningTestSuite() { val (alice5, actionsAlice5) = alice4.process(ChannelCommand.MessageReceived(txSigsBob)) assertIs>(alice5) assertEquals(alice5.state.commitments.active.size, 2) - assertEquals(actionsAlice5.size, 5) + assertEquals(actionsAlice5.size, 8) assertEquals(actionsAlice5.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.FundingTx).txid, spliceTxId) + assertEquals(htlcs.bobToAlice.map { it.second }.toSet(), actionsAlice5.filterIsInstance().map { it.add }.toSet()) actionsAlice5.hasWatchConfirmed(spliceTxId) actionsAlice5.has() actionsAlice5.has() @@ -499,7 +551,8 @@ class SpliceTestsCommon : LightningTestSuite() { @Test fun `disconnect -- tx_signatures sent by bob -- zero-conf`() { val (alice, bob) = reachNormalWithConfirmedFundingTx(zeroConf = true) - val (alice1, commitSigAlice1, bob1, _) = spliceOutWithoutSigs(alice, bob, 20_000.sat) + val (alice0, bob0, htlcs) = setupHtlcs(alice, bob) + val (alice1, commitSigAlice1, bob1, _) = initiateSpliceWithoutSigs(alice0, bob0, inAmounts = listOf(50_000.sat), pushAmount = 0.msat, outAmount = 100_000.sat) val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(commitSigAlice1)) assertIs>(bob2) val spliceTxId = actionsBob2.hasOutgoingMessage().txId @@ -509,17 +562,19 @@ class SpliceTestsCommon : LightningTestSuite() { val (alice2, bob3, channelReestablishAlice) = disconnect(alice1, bob2) assertEquals(channelReestablishAlice.nextFundingTxId, spliceTxId) val (bob4, actionsBob4) = bob3.process(ChannelCommand.MessageReceived(channelReestablishAlice)) - assertEquals(actionsBob4.size, 4) + assertEquals(actionsBob4.size, 6) val channelReestablishBob = actionsBob4.findOutgoingMessage() val commitSigBob2 = actionsBob4.findOutgoingMessage() val txSigsBob = actionsBob4.findOutgoingMessage() // splice_locked must always be sent *after* tx_signatures assertIs(actionsBob4.filterIsInstance().last().message) val spliceLockedBob = actionsBob4.findOutgoingMessage() + assertEquals(htlcs.aliceToBob.map { it.second }.toSet(), actionsBob4.filterIsInstance().map { it.add }.toSet()) assertEquals(channelReestablishBob.nextFundingTxId, spliceTxId) val (alice3, actionsAlice3) = alice2.process(ChannelCommand.MessageReceived(channelReestablishBob)) - assertEquals(actionsAlice3.size, 1) + assertEquals(actionsAlice3.size, 3) val commitSigAlice2 = actionsAlice3.findOutgoingMessage() + assertEquals(htlcs.bobToAlice.map { it.second }.toSet(), actionsAlice3.filterIsInstance().map { it.add }.toSet()) assertEquals(commitSigAlice1.signature, commitSigAlice2.signature) val (alice4, actionsAlice4) = alice3.process(ChannelCommand.MessageReceived(commitSigBob2)) @@ -527,7 +582,7 @@ class SpliceTestsCommon : LightningTestSuite() { val (alice5, actionsAlice5) = alice4.process(ChannelCommand.MessageReceived(txSigsBob)) assertIs>(alice5) assertEquals(alice5.state.commitments.active.size, 2) - assertEquals(actionsAlice5.size, 6) + assertEquals(actionsAlice5.size, 9) assertEquals(actionsAlice5.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.FundingTx).txid, spliceTxId) actionsAlice5.hasWatchConfirmed(spliceTxId) actionsAlice5.has() @@ -535,6 +590,7 @@ class SpliceTestsCommon : LightningTestSuite() { val txSigsAlice = actionsAlice5.findOutgoingMessage() assertIs(actionsAlice5.filterIsInstance().last().message) val spliceLockedAlice = actionsAlice5.findOutgoingMessage() + assertEquals(htlcs.bobToAlice.map { it.second }.toSet(), actionsAlice5.filterIsInstance().map { it.add }.toSet()) val (alice6, actionsAlice6) = alice5.process(ChannelCommand.MessageReceived(spliceLockedBob)) assertIs>(alice6) assertEquals(alice6.state.commitments.active.size, 1) @@ -556,12 +612,15 @@ class SpliceTestsCommon : LightningTestSuite() { assertEquals(actionsBob7.size, 2) actionsBob7.find().also { assertEquals(it.txId, spliceTxId) } actionsBob7.has() + val fee = spliceFee(alice6, 950_000.sat) + resolveHtlcs(alice6, bob7, htlcs, spliceOutFee = fee, commitmentsCount = 1) } @Test fun `disconnect -- tx_signatures sent by alice -- confirms while bob is offline`() { val (alice, bob) = reachNormalWithConfirmedFundingTx() - val (alice1, commitSigAlice1, bob1, commitSigBob1) = spliceOutWithoutSigs(alice, bob, 20_000.sat) + val (alice0, bob0, htlcs) = setupHtlcs(alice, bob) + val (alice1, commitSigAlice1, bob1, commitSigBob1) = initiateSpliceWithoutSigs(alice0, bob0, inAmounts = listOf(50_000.sat), pushAmount = 0.msat, outAmount = 100_000.sat) // Bob completes the splice, but is missing Alice's tx_signatures. val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(commitSigAlice1)) @@ -589,15 +648,17 @@ class SpliceTestsCommon : LightningTestSuite() { val (alice5, bob3, channelReestablishAlice) = disconnect(alice4, bob2) assertNull(channelReestablishAlice.nextFundingTxId) val (bob4, actionsBob4) = bob3.process(ChannelCommand.MessageReceived(channelReestablishAlice)) - assertEquals(1, actionsBob4.size) + assertEquals(3, actionsBob4.size) val channelReestablishBob = actionsBob4.findOutgoingMessage() + assertEquals(htlcs.aliceToBob.map { it.second }.toSet(), actionsBob4.filterIsInstance().map { it.add }.toSet()) assertEquals(channelReestablishBob.nextFundingTxId, spliceTx.txid) val (alice6, actionsAlice6) = alice5.process(ChannelCommand.MessageReceived(channelReestablishBob)) assertIs>(alice6) assertEquals(alice6.state.spliceStatus, SpliceStatus.None) - assertEquals(2, actionsAlice6.size) + assertEquals(4, actionsAlice6.size) val txSigsAlice = actionsAlice6.hasOutgoingMessage() actionsAlice6.hasOutgoingMessage() + assertEquals(htlcs.bobToAlice.map { it.second }.toSet(), actionsAlice6.filterIsInstance().map { it.add }.toSet()) // Bob receives tx_signatures, which completes the splice. val (bob5, actionsBob5) = bob4.process(ChannelCommand.MessageReceived(txSigsAlice)) @@ -606,12 +667,15 @@ class SpliceTestsCommon : LightningTestSuite() { assertEquals(2, actionsBob5.size) actionsBob5.hasPublishTx(spliceTx) actionsBob5.has() + val fee = spliceFee(alice6, 950_000.sat) + resolveHtlcs(alice6, bob5, htlcs, spliceOutFee = fee, commitmentsCount = 2) } @Test fun `disconnect -- tx_signatures received by alice`() { val (alice, bob) = reachNormalWithConfirmedFundingTx() - val (alice1, commitSigAlice, bob1, commitSigBob) = spliceOutWithoutSigs(alice, bob, 20_000.sat) + val (alice0, bob0, htlcs) = setupHtlcs(alice, bob) + val (alice1, commitSigAlice, bob1, commitSigBob) = initiateSpliceWithoutSigs(alice0, bob0, inAmounts = listOf(50_000.sat), pushAmount = 0.msat, outAmount = 100_000.sat) val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(commitSigBob)) assertTrue(actionsAlice2.isEmpty()) val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(commitSigAlice)) @@ -626,14 +690,16 @@ class SpliceTestsCommon : LightningTestSuite() { val (alice4, bob3, channelReestablishAlice) = disconnect(alice3, bob2) assertNull(channelReestablishAlice.nextFundingTxId) val (bob4, actionsBob4) = bob3.process(ChannelCommand.MessageReceived(channelReestablishAlice)) - assertEquals(actionsBob4.size, 1) + assertEquals(actionsBob4.size, 3) val channelReestablishBob = actionsBob4.findOutgoingMessage() + assertEquals(htlcs.aliceToBob.map { it.second }.toSet(), actionsBob4.filterIsInstance().map { it.add }.toSet()) assertEquals(channelReestablishBob.nextFundingTxId, spliceTx.txid) val (alice5, actionsAlice5) = alice4.process(ChannelCommand.MessageReceived(channelReestablishBob)) assertIs>(alice5) assertEquals(alice5.state.commitments.active.size, 2) - assertEquals(actionsAlice5.size, 1) + assertEquals(actionsAlice5.size, 3) val txSigsAlice = actionsAlice5.findOutgoingMessage() + assertEquals(htlcs.bobToAlice.map { it.second }.toSet(), actionsAlice5.filterIsInstance().map { it.add }.toSet()) val (bob5, actionsBob5) = bob4.process(ChannelCommand.MessageReceived(txSigsAlice)) assertIs>(bob5) @@ -641,12 +707,15 @@ class SpliceTestsCommon : LightningTestSuite() { assertEquals(actionsBob5.size, 2) assertEquals(actionsBob5.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.FundingTx).txid, spliceTx.txid) actionsBob5.has() + val fee = spliceFee(alice5, 950_000.sat) + resolveHtlcs(alice5, bob5, htlcs, spliceOutFee = fee, commitmentsCount = 2) } @Test fun `disconnect -- splice_locked sent`() { val (alice, bob) = reachNormalWithConfirmedFundingTx() - val (alice1, bob1) = spliceOut(alice, bob, 70_000.sat) + val (alice0, bob0, htlcs) = setupHtlcs(alice, bob) + val (alice1, bob1) = initiateSplice(alice0, bob0, inAmounts = listOf(50_000.sat), pushAmount = 0.msat, outAmount = 100_000.sat) val spliceTx = alice1.commitments.latest.localFundingStatus.signedTx!! val (alice2, actionsAlice2) = alice1.process(ChannelCommand.WatchReceived(WatchEventConfirmed(alice.channelId, BITCOIN_FUNDING_DEPTHOK, 100, 0, spliceTx))) @@ -660,13 +729,15 @@ class SpliceTestsCommon : LightningTestSuite() { // Alice disconnects before receiving Bob's splice_locked. val (alice3, bob4, channelReestablishAlice) = disconnect(alice2, bob3) val (bob5, actionsBob5) = bob4.process(ChannelCommand.MessageReceived(channelReestablishAlice)) - assertEquals(actionsBob5.size, 2) + assertEquals(actionsBob5.size, 4) val channelReestablishBob = actionsBob5.findOutgoingMessage() val spliceLockedBob = actionsBob5.findOutgoingMessage() + assertEquals(htlcs.aliceToBob.map { it.second }.toSet(), actionsBob5.filterIsInstance().map { it.add }.toSet()) val (alice4, actionsAlice4) = alice3.process(ChannelCommand.MessageReceived(channelReestablishBob)) - assertEquals(actionsAlice4.size, 1) + assertEquals(actionsAlice4.size, 3) val spliceLockedAlice2 = actionsAlice4.hasOutgoingMessage() + assertEquals(htlcs.bobToAlice.map { it.second }.toSet(), actionsAlice4.filterIsInstance().map { it.add }.toSet()) val (alice5, actionsAlice5) = alice4.process(ChannelCommand.MessageReceived(spliceLockedBob)) assertIs>(alice5) assertEquals(alice5.state.commitments.active.size, 1) @@ -679,13 +750,18 @@ class SpliceTestsCommon : LightningTestSuite() { assertEquals(bob6.state.commitments.active.size, 1) assertEquals(actionsBob6.size, 1) actionsBob6.has() + val fee = spliceFee(alice5, 950_000.sat) + resolveHtlcs(alice5, bob6, htlcs, spliceOutFee = fee, commitmentsCount = 1) } @Test fun `disconnect -- latest commitment locked remotely and locally -- zero-conf`() { val (alice, bob) = reachNormalWithConfirmedFundingTx(zeroConf = true) - val (alice1, bob1) = spliceOut(alice, bob, 40_000.sat) - val (alice2, bob2) = spliceOut(alice1, bob1, 30_000.sat) + val (alice0, bob0, htlcs) = setupHtlcs(alice, bob) + val (alice1, bob1) = spliceIn(alice0, bob0, listOf(50_000.sat)) + val fee1 = spliceFee(alice1, capacity = 1_050_000.sat) + val (alice2, bob2) = spliceOut(alice1, bob1, 100_000.sat) + val fee2 = spliceFee(alice2, capacity = 950_000.sat - fee1) // Alice and Bob have not received any remote splice_locked yet. assertEquals(alice2.commitments.active.size, 3) @@ -696,14 +772,16 @@ class SpliceTestsCommon : LightningTestSuite() { // On reconnection, Alice and Bob only send splice_locked for the latest commitment. val (alice3, bob3, channelReestablishAlice) = disconnect(alice2, bob2) val (bob4, actionsBob4) = bob3.process(ChannelCommand.MessageReceived(channelReestablishAlice)) - assertEquals(actionsBob4.size, 2) + assertEquals(actionsBob4.size, 4) val channelReestablishBob = actionsBob4.findOutgoingMessage() val spliceLockedBob = actionsBob4.findOutgoingMessage() + assertEquals(htlcs.aliceToBob.map { it.second }.toSet(), actionsBob4.filterIsInstance().map { it.add }.toSet()) assertEquals(spliceLockedBob.fundingTxId, bob2.commitments.latest.fundingTxId) val (alice4, actionsAlice4) = alice3.process(ChannelCommand.MessageReceived(channelReestablishBob)) - assertEquals(actionsAlice4.size, 1) + assertEquals(actionsAlice4.size, 3) val spliceLockedAlice = actionsAlice4.hasOutgoingMessage() + assertEquals(htlcs.bobToAlice.map { it.second }.toSet(), actionsAlice4.filterIsInstance().map { it.add }.toSet()) assertEquals(spliceLockedAlice.fundingTxId, spliceLockedBob.fundingTxId) val (alice5, actionsAlice5) = alice4.process(ChannelCommand.MessageReceived(spliceLockedBob)) assertEquals(actionsAlice5.size, 3) @@ -720,14 +798,20 @@ class SpliceTestsCommon : LightningTestSuite() { actionsBob5.has() assertContains(actionsBob5, ChannelAction.Storage.SetLocked(bob1.commitments.latest.fundingTxId)) assertContains(actionsBob5, ChannelAction.Storage.SetLocked(bob2.commitments.latest.fundingTxId)) + assertIs>(alice5) + assertIs>(bob5) + resolveHtlcs(alice5, bob5, htlcs, spliceOutFee = fee1 + fee2, commitmentsCount = 1) } @Test fun `disconnect -- latest commitment locked remotely but not locally`() { val (alice, bob) = reachNormalWithConfirmedFundingTx() - val (alice1, bob1) = spliceOut(alice, bob, 40_000.sat) + val (alice0, bob0, htlcs) = setupHtlcs(alice, bob) + val (alice1, bob1) = spliceIn(alice0, bob0, listOf(50_000.sat)) + val fee1 = spliceFee(alice1, capacity = 1_050_000.sat) val spliceTx1 = alice1.commitments.latest.localFundingStatus.signedTx!! - val (alice2, bob2) = spliceOut(alice1, bob1, 30_000.sat) + val (alice2, bob2) = spliceOut(alice1, bob1, 100_000.sat) + val fee2 = spliceFee(alice2, capacity = 950_000.sat - fee1) val spliceTx2 = alice2.commitments.latest.localFundingStatus.signedTx!! assertNotEquals(spliceTx1.txid, spliceTx2.txid) @@ -757,14 +841,16 @@ class SpliceTestsCommon : LightningTestSuite() { // On reconnection, the latest commitment is still unlocked by Bob so they have two active commitments. val (alice4, bob4, channelReestablishAlice) = disconnect(alice3, bob3) val (bob5, actionsBob5) = bob4.process(ChannelCommand.MessageReceived(channelReestablishAlice)) - assertEquals(actionsBob5.size, 2) + assertEquals(actionsBob5.size, 4) val channelReestablishBob = actionsBob5.findOutgoingMessage() val spliceLockedBob = actionsBob5.findOutgoingMessage() + assertEquals(htlcs.aliceToBob.map { it.second }.toSet(), actionsBob5.filterIsInstance().map { it.add }.toSet()) assertEquals(spliceLockedBob.fundingTxId, spliceTx1.txid) val (alice5, actionsAlice5) = alice4.process(ChannelCommand.MessageReceived(channelReestablishBob)) - assertEquals(actionsAlice5.size, 1) + assertEquals(actionsAlice5.size, 3) val spliceLockedAlice = actionsAlice5.hasOutgoingMessage() + assertEquals(htlcs.bobToAlice.map { it.second }.toSet(), actionsAlice5.filterIsInstance().map { it.add }.toSet()) assertEquals(spliceLockedAlice.fundingTxId, spliceTx2.txid) val (alice6, actionsAlice6) = alice5.process(ChannelCommand.MessageReceived(spliceLockedBob)) assertEquals(actionsAlice6.size, 2) @@ -777,12 +863,16 @@ class SpliceTestsCommon : LightningTestSuite() { assertEquals(bob6.commitments.active.map { it.fundingTxId }, listOf(spliceTx2.txid, spliceTx1.txid)) actionsBob6.has() actionsBob6.contains(ChannelAction.Storage.SetLocked(spliceTx1.txid)) + assertIs>(alice6) + assertIs>(bob6) + resolveHtlcs(alice6, bob6, htlcs, spliceOutFee = fee1 + fee2, commitmentsCount = 2) } @Test fun `disconnect -- splice tx published`() { val (alice, bob) = reachNormalWithConfirmedFundingTx() - val (alice1, bob1) = spliceOut(alice, bob, 40_000.sat) + val (alice0, bob0, _) = setupHtlcs(alice, bob) + val (alice1, bob1) = spliceOut(alice0, bob0, 40_000.sat) val spliceTx = alice1.commitments.latest.localFundingStatus.signedTx!! val (alice2, _) = alice1.process(ChannelCommand.Disconnected) @@ -801,13 +891,14 @@ class SpliceTestsCommon : LightningTestSuite() { @Test fun `force-close -- latest active commitment`() { val (alice, bob) = reachNormalWithConfirmedFundingTx() - val (alice1, bob1) = spliceOut(alice, bob, 75_000.sat) + val (alice0, bob0, _) = setupHtlcs(alice, bob) + val (alice1, bob1) = spliceOut(alice0, bob0, 75_000.sat) // Bob force-closes using the latest active commitment. val bobCommitTx = bob1.commitments.active.first().localCommit.publishableTxs.commitTx.tx val (bob2, actionsBob2) = bob1.process(ChannelCommand.Close.ForceClose) assertIs(bob2.state) - assertEquals(actionsBob2.size, 7) + assertEquals(actionsBob2.size, 17) assertEquals(actionsBob2.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.CommitTx).txid, bobCommitTx.txid) val claimMain = actionsBob2.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.ClaimLocalDelayedOutputTx) actionsBob2.hasWatchConfirmed(bobCommitTx.txid) @@ -842,12 +933,13 @@ class SpliceTestsCommon : LightningTestSuite() { @Test fun `force-close -- previous active commitment`() { val (alice, bob) = reachNormalWithConfirmedFundingTx() - val (alice1, bob1) = spliceOut(alice, bob, 75_000.sat) + val (alice0, bob0, _) = setupHtlcs(alice, bob) + val (alice1, bob1) = spliceOut(alice0, bob0, 75_000.sat) // Bob force-closes using an older active commitment. assertEquals(bob1.commitments.active.map { it.localCommit.publishableTxs.commitTx.tx }.toSet().size, 2) val bobCommitTx = bob1.commitments.active.last().localCommit.publishableTxs.commitTx.tx - handlePreviousRemoteClose(alice1, bobCommitTx) + handlePreviousRemoteClose(alice1, bob1.commitments.active.last(), bobCommitTx) } @Test @@ -861,13 +953,14 @@ class SpliceTestsCommon : LightningTestSuite() { // Bob force-closes using an older active commitment with an alternative feerate. assertEquals(bob4.commitments.active.map { it.localCommit.publishableTxs.commitTx.tx }.toSet().size, 3) val bobCommitTx = useAlternativeCommitSig(bob4, bob4.commitments.active[1], commitSigAlice1.alternativeFeerateSigs.first()) - handlePreviousRemoteClose(alice4, bobCommitTx) + handlePreviousRemoteClose(alice4, bob4.commitments.active[1], bobCommitTx) } @Test fun `force-close -- previous inactive commitment`() { val (alice, bob) = reachNormalWithConfirmedFundingTx(zeroConf = true) - val (alice1, bob1) = spliceOut(alice, bob, 50_000.sat) + val (alice0, bob0, _) = setupHtlcs(alice, bob) + val (alice1, bob1) = spliceOut(alice0, bob0, 50_000.sat) val spliceTx = alice1.commitments.latest.localFundingStatus.signedTx!! val (alice2, _) = alice1.process(ChannelCommand.MessageReceived(SpliceLocked(alice.channelId, spliceTx.hash))) assertEquals(alice2.commitments.active.size, 1) @@ -879,13 +972,14 @@ class SpliceTestsCommon : LightningTestSuite() { // Bob force-closes using an inactive commitment. assertNotEquals(bob2.commitments.active.first().fundingTxId, bob2.commitments.inactive.first().fundingTxId) val bobCommitTx = bob2.commitments.inactive.first().localCommit.publishableTxs.commitTx.tx - handlePreviousRemoteClose(alice1, bobCommitTx) + handlePreviousRemoteClose(alice1, bob2.commitments.inactive.first(), bobCommitTx) } @Test fun `force-close -- revoked latest active commitment`() { val (alice, bob) = reachNormalWithConfirmedFundingTx() - val (alice1, bob1) = spliceOut(alice, bob, 50_000.sat) + val (alice0, bob0, _) = setupHtlcs(alice, bob) + val (alice1, bob1) = spliceOut(alice0, bob0, 50_000.sat) val bobCommitTx = bob1.commitments.active.first().localCommit.publishableTxs.commitTx.tx // Alice sends an HTLC to Bob, which revokes the previous commitment. @@ -918,7 +1012,8 @@ class SpliceTestsCommon : LightningTestSuite() { @Test fun `force-close -- revoked previous active commitment`() { val (alice, bob) = reachNormalWithConfirmedFundingTx() - val (alice1, bob1) = spliceOut(alice, bob, 50_000.sat) + val (alice0, bob0, _) = setupHtlcs(alice, bob) + val (alice1, bob1) = spliceOut(alice0, bob0, 50_000.sat) val bobCommitTx = bob1.commitments.active.last().localCommit.publishableTxs.commitTx.tx // Alice sends an HTLC to Bob, which revokes the previous commitment. @@ -936,7 +1031,8 @@ class SpliceTestsCommon : LightningTestSuite() { @Test fun `force-close -- revoked previous inactive commitment`() { val (alice, bob) = reachNormalWithConfirmedFundingTx(zeroConf = true) - val (alice1, bob1) = spliceOut(alice, bob, 50_000.sat) + val (alice0, bob0, _) = setupHtlcs(alice, bob) + val (alice1, bob1) = spliceOut(alice0, bob0, 50_000.sat) val spliceTx = alice1.commitments.latest.localFundingStatus.signedTx!! val (alice2, _) = alice1.process(ChannelCommand.MessageReceived(SpliceLocked(alice.channelId, spliceTx.hash))) assertIs>(alice2) @@ -958,20 +1054,131 @@ class SpliceTestsCommon : LightningTestSuite() { handlePreviousRevokedRemoteClose(alice6, bobCommitTx) } + @Test + fun `splice funds in and out with pre and post splice htlcs`() { + val (alice, bob) = reachNormalWithConfirmedFundingTx() + val (alice1, bob1, htlcs) = setupHtlcs(alice, bob) + + val (alice2, bob2) = initiateSplice(alice1, bob1, inAmounts = listOf(50_000.sat), pushAmount = 10_000_000.msat, outAmount = 100_000.sat) + + // bob sends an HTLC that is applied to both commitments + val (nodes3, preimage, add) = addHtlc(10_000_000.msat, bob2, alice2) + val (bob4, alice4) = crossSign(nodes3.first, nodes3.second, commitmentsCount = 2) + + alice4.commitments.active.forEach { c -> + val commitTx = c.localCommit.publishableTxs.commitTx.tx + Transaction.correctlySpends(commitTx, mapOf(c.commitInput.outPoint to c.commitInput.txOut), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + } + bob4.state.commitments.active.forEach { c -> + val commitTx = c.localCommit.publishableTxs.commitTx.tx + Transaction.correctlySpends(commitTx, mapOf(c.commitInput.outPoint to c.commitInput.txOut), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + } + + // bob fulfills that HTLC in both commitments + val (bob5, alice5) = fulfillHtlc(add.id, preimage, bob4, alice4) + val (alice6, bob6) = crossSign(alice5, bob5, commitmentsCount = 2) + + alice.state.commitments.active.forEach { c -> + val commitTx = c.localCommit.publishableTxs.commitTx.tx + Transaction.correctlySpends(commitTx, mapOf(c.commitInput.outPoint to c.commitInput.txOut), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + } + bob.state.commitments.active.forEach { c -> + val commitTx = c.localCommit.publishableTxs.commitTx.tx + Transaction.correctlySpends(commitTx, mapOf(c.commitInput.outPoint to c.commitInput.txOut), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + } + + val fee = spliceFee(alice6, 950_000.sat) + resolveHtlcs(alice6, bob6, htlcs, spliceOutFee = fee, commitmentsCount = 2) + } + + @Test + fun `splice in and out with pending htlcs resolved after splice locked`() { + val (alice, bob) = reachNormalWithConfirmedFundingTx() + val (alice1, bob1, htlcs) = setupHtlcs(alice, bob) + val (alice2, bob2) = initiateSplice(alice1, bob1, inAmounts = listOf(50_000.sat), pushAmount = 0.msat, outAmount = 100_000.sat) + val spliceTx = alice2.commitments.latest.localFundingStatus.signedTx!! + val (alice3, actionsAlice3) = alice2.process(ChannelCommand.WatchReceived(WatchEventConfirmed(alice2.channelId, BITCOIN_FUNDING_DEPTHOK, 42, 0, spliceTx))) + val (bob3, _) = bob2.process(ChannelCommand.MessageReceived(actionsAlice3.findOutgoingMessage())) + val (bob4, actionsBob4) = bob3.process(ChannelCommand.WatchReceived(WatchEventConfirmed(bob3.channelId, BITCOIN_FUNDING_DEPTHOK, 42, 0, spliceTx))) + val (alice4, _) = alice3.process(ChannelCommand.MessageReceived(actionsBob4.findOutgoingMessage())) + assertIs>(alice4) + assertIs>(bob4) + val fee = spliceFee(alice4, 950_000.sat) + resolveHtlcs(alice4, bob4, htlcs, fee, commitmentsCount = 1) + } + + @Test + fun `splice funds in and out with htlcs`() { + val (alice, bob) = reachNormalWithConfirmedFundingTx() + val (alice1, bob1, htlcs) = setupHtlcs(alice, bob) + val (alice2, bob2) = spliceIn(alice1, bob1, listOf(50_000.sat)) + val fee1 = spliceFee(alice2, capacity = 1_050_000.sat) + val (alice3, bob3) = spliceOut(alice2, bob2, 100_000.sat) + val fee2 = spliceFee(alice3, capacity = 950_000.sat - fee1) + resolveHtlcs(alice3, bob3, htlcs, fee1+fee2, commitmentsCount = 3) + } + + @Test + fun `recv invalid htlc signatures during splice-in`() { + val (alice, bob) = reachNormalWithConfirmedFundingTx() + val (alice1, bob1, htlcs) = setupHtlcs(alice, bob) + val (alice2, commitSigAlice, bob2, commitSigBob) = initiateSpliceWithoutSigs(alice1, bob1, inAmounts = listOf(50_000.sat), pushAmount = 0.msat) + + assertEquals(commitSigAlice.htlcSignatures.size, 4) + assertEquals(commitSigBob.htlcSignatures.size, 4) + val (alice3, _) = alice2.process(ChannelCommand.MessageReceived(commitSigBob)) + + val (bob3, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(commitSigAlice.copy(htlcSignatures = commitSigAlice.htlcSignatures.asReversed()))) + val (alice4, actionsAlice4) = alice3.process(ChannelCommand.MessageReceived(actionsBob3.findOutgoingMessage())) + val (bob4, _) = bob3.process(ChannelCommand.MessageReceived(actionsAlice4.findOutgoingMessage())) + + // resolve pre-splice HTLCs after aborting the splice attempt + val (preimage1a, htlc1a) = htlcs.aliceToBob.first() + val (preimage2a, htlc2a) = htlcs.aliceToBob.last() + val (preimage1b, htlc1b) = htlcs.bobToAlice.first() + val (preimage2b, htlc2b) = htlcs.bobToAlice.last() + val nodes1 = fulfillHtlc(htlc1a.id, preimage1a, alice4, bob4) + val nodes2 = fulfillHtlc(htlc2a.id, preimage2a, nodes1.first, nodes1.second) + val nodes3 = fulfillHtlc(htlc1b.id, preimage1b, nodes2.second, nodes2.first) + val nodes4 = fulfillHtlc(htlc2b.id, preimage2b, nodes3.first, nodes3.second) + assertIs,LNChannel>>(nodes4) + val nodes5 = crossSign(nodes4.first, nodes4.second, 1) + + assertEquals(nodes5.second.state.commitments.latest.localCommit.spec.toLocal, 805_000_000.msat) + assertEquals(nodes5.second.state.commitments.latest.localCommit.spec.toRemote, 195_000_000.msat) + } + + @Test + fun `splice-out would go below reserve`() { + val (alice, bob) = reachNormalWithConfirmedFundingTx() + val (alice1, bob1, _) = setupHtlcs(alice, bob) + val cmd = ChannelCommand.Commitment.Splice.Request( + replyTo = CompletableDeferred(), + spliceIn = null, + spliceOut = ChannelCommand.Commitment.Splice.Request.SpliceOut(760_000.sat, Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector()), + feerate = FeeratePerKw(253.sat) + ) + val (alice2, actionsAlice2) = alice1.process(cmd) + val aliceStfu = actionsAlice2.findOutgoingMessage() + val (_, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(aliceStfu)) + val bobStfu = actionsBob2.findOutgoingMessage() + val (_, actionsAlice3) = alice2.process(ChannelCommand.MessageReceived(bobStfu)) + actionsAlice3.findOutgoingMessage() + runBlocking { + val response = cmd.replyTo.await() + assertIs(response) + } + } + companion object { private fun reachNormalWithConfirmedFundingTx(zeroConf: Boolean = false): Pair, LNChannel> { val (alice, bob) = reachNormal(zeroConf = zeroConf) val fundingTx = alice.commitments.latest.localFundingStatus.signedTx!! val (alice1, _) = alice.process(ChannelCommand.WatchReceived(WatchEventConfirmed(alice.channelId, BITCOIN_FUNDING_DEPTHOK, 42, 3, fundingTx))) val (bob1, _) = bob.process(ChannelCommand.WatchReceived(WatchEventConfirmed(bob.channelId, BITCOIN_FUNDING_DEPTHOK, 42, 3, fundingTx))) - val (nodes2, preimage, htlc) = addHtlc(5_000.msat, alice1, bob1) - val (alice2, bob2) = nodes2 - assertIs>(alice2) - assertIs>(bob2) - val (alice3, bob3) = crossSign(alice2, bob2, commitmentsCount = 1) - val (alice4, bob4) = fulfillHtlc(htlc.id, preimage, alice3, bob3) - val (bob5, alice5) = crossSign(bob4, alice4, commitmentsCount = 1) - return Pair(alice5, bob5) + assertIs>(alice1) + assertIs>(bob1) + return Pair(alice1, bob1) } private fun createSpliceOutRequest(amount: Satoshi): ChannelCommand.Commitment.Splice.Request = ChannelCommand.Commitment.Splice.Request( @@ -1088,11 +1295,79 @@ class SpliceTestsCommon : LightningTestSuite() { return exchangeSpliceSigs(alice4, commitSigAlice, bob4, commitSigBob) } + private fun initiateSpliceWithoutSigs(alice: LNChannel, bob: LNChannel, inAmounts: List = emptyList(), pushAmount: MilliSatoshi = 0.msat, outAmount: Satoshi? = null): UnsignedSpliceFixture { + val spliceIn_opt = if (inAmounts.isNotEmpty()) { + ChannelCommand.Commitment.Splice.Request.SpliceIn(createWalletWithFunds(alice.staticParams.nodeParams.keyManager, inAmounts), pushAmount) + } else { + null + } + val spliceOut_opt = if (outAmount != null) { + ChannelCommand.Commitment.Splice.Request.SpliceOut(outAmount, Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector()) + } else { + null + } + val parentCommitment = alice.commitments.active.first() + val cmd = ChannelCommand.Commitment.Splice.Request( + replyTo = CompletableDeferred(), + spliceIn = spliceIn_opt, + spliceOut = spliceOut_opt, + feerate = FeeratePerKw(253.sat) + ) + + val (alice1, bobQuiescent, spliceInit) = reachQuiescent(cmd, alice, bob) + val (bob1, actionsBob1) = bobQuiescent.process(ChannelCommand.MessageReceived(spliceInit)) + val spliceAck = actionsBob1.findOutgoingMessage() + assertEquals(spliceAck.fundingContribution, 0.sat) + val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(spliceAck)) + val (alice4, bob3, actionsAlice4) = if (spliceIn_opt != null) { + val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(actionsAlice2.findOutgoingMessage())) + val (alice3, actionsAlice3) = alice2.process(ChannelCommand.MessageReceived(actionsBob2.findOutgoingMessage())) + Triple(alice3, bob2, actionsAlice3) + } else { + Triple(alice2, bob1, actionsAlice2) + } + + // alice adds shared input + val (bob4, actionsBob4) = bob3.process(ChannelCommand.MessageReceived(actionsAlice4.findOutgoingMessage())) + val (alice5, actionsAlice5) = alice4.process(ChannelCommand.MessageReceived(actionsBob4.findOutgoingMessage())) + + // alice adds shared output + val (bob5, actionsBob5) = bob4.process(ChannelCommand.MessageReceived(actionsAlice5.findOutgoingMessage())) + val (alice6, actionsAlice6) = alice5.process(ChannelCommand.MessageReceived(actionsBob5.findOutgoingMessage())) + + val (alice8, bob6, actionsAlice8) = if (spliceOut_opt != null) { + val (bob6, actionsBob6) = bob5.process(ChannelCommand.MessageReceived(actionsAlice6.findOutgoingMessage())) + val (alice7, actionsAlice7) = alice6.process(ChannelCommand.MessageReceived(actionsBob6.findOutgoingMessage())) + Triple(alice7, bob6, actionsAlice7) + } else { + Triple(alice6, bob5, actionsAlice6) + } + + val commitSigAlice = actionsAlice8.findOutgoingMessage() + actionsAlice8.has() + val (bob8, actionsBob8) = bob6.process(ChannelCommand.MessageReceived(actionsAlice8.findOutgoingMessage())) + assertIs>(bob8) + val commitSigBob = actionsBob8.findOutgoingMessage() + actionsBob8.has() + + checkCommandResponse(cmd.replyTo, parentCommitment, spliceInit) + assertIs>(alice8) + return UnsignedSpliceFixture(alice8, commitSigAlice, bob8, commitSigBob) + } + + private fun initiateSplice(alice: LNChannel, bob: LNChannel, inAmounts: List = emptyList(), pushAmount: MilliSatoshi = 0.msat, outAmount: Satoshi? = null): Pair, LNChannel> { + val (alice1, commitSigAlice, bob1, commitSigBob) = initiateSpliceWithoutSigs(alice, bob, inAmounts, pushAmount, outAmount) + assertIs>(alice1) + assertIs>(bob1) + + return exchangeSpliceSigs(alice1, commitSigAlice, bob1, commitSigBob) + } + private fun checkCommandResponse(replyTo: CompletableDeferred, parentCommitment: Commitment, spliceInit: SpliceInit): ByteVector32 = runBlocking { val response = replyTo.await() assertIs(response) assertEquals(response.capacity, parentCommitment.fundingAmount + spliceInit.fundingContribution) - assertEquals(response.balance, parentCommitment.localCommit.spec.toLocal + spliceInit.fundingContribution.toMilliSatoshi()) + assertEquals(response.balance, parentCommitment.localCommit.spec.toLocal + spliceInit.fundingContribution.toMilliSatoshi() - spliceInit.pushAmount) assertEquals(response.fundingTxIndex, parentCommitment.fundingTxIndex + 1) response.fundingTxId } @@ -1106,9 +1381,10 @@ class SpliceTestsCommon : LightningTestSuite() { val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(commitSigBob)) assertTrue(actionsAlice1.isEmpty()) val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(commitSigAlice)) + val incomingHtlcs = bob1.commitments.latest.localCommit.spec.htlcs.incomings() when { - bob1.staticParams.useZeroConf -> assertEquals(actionsBob1.size, 4) - else -> assertEquals(actionsBob1.size, 3) + bob1.staticParams.useZeroConf -> assertEquals(actionsBob1.size, 4 + incomingHtlcs.size) + else -> assertEquals(actionsBob1.size, 3 + incomingHtlcs.size) } val txSigsBob = actionsBob1.findOutgoingMessage() assertEquals(txSigsBob.swapInServerSigs.size, aliceSpliceStatus.session.fundingTx.tx.localInputs.size) @@ -1185,7 +1461,6 @@ class SpliceTestsCommon : LightningTestSuite() { /** Full remote commit resolution from tx detection to channel close */ private fun handleRemoteClose(channel1: LNChannel, actions1: List, commitment: Commitment, remoteCommitTx: Transaction) { assertIs(channel1.state) - assertEquals(0, commitment.remoteCommit.spec.htlcs.size, "this helper only supports remote-closing without htlcs") // Spend our outputs from the remote commitment. actions1.has() @@ -1202,18 +1477,25 @@ class SpliceTestsCommon : LightningTestSuite() { // Claim main output confirms. val (channel3, actions3) = channel2.process(ChannelCommand.WatchReceived(WatchEventConfirmed(channel1.channelId, BITCOIN_TX_CONFIRMED(claimRemoteDelayedOutputTx), channel2.currentBlockHeight, 43, claimRemoteDelayedOutputTx))) - assertIs(channel3.state) - assertEquals(actions3.size, 2) - actions3.has() - actions3.has() + if (commitment.remoteCommit.spec.htlcs.isEmpty()) { + assertIs(channel3.state) + assertEquals(actions3.size, 2) + actions3.has() + actions3.has() + } else { + // Htlc outputs must be resolved before channel is closed. + assertIs(channel2.state) + assertEquals(actions2.size, 1) + actions2.has() + } } - private fun handlePreviousRemoteClose(alice1: LNChannel, bobCommitTx: Transaction) { + private fun handlePreviousRemoteClose(alice1: LNChannel, commitment: Commitment, bobCommitTx: Transaction) { // Alice detects the force-close. val (alice2, actionsAlice2) = alice1.process(ChannelCommand.WatchReceived(WatchEventSpent(alice1.channelId, BITCOIN_FUNDING_SPENT, bobCommitTx))) assertIs(alice2.state) // Alice attempts to force-close and in parallel puts a watch on the remote commit. - assertEquals(actionsAlice2.size, 7) + assertEquals(actionsAlice2.size, 7 + commitment.remoteCommit.spec.htlcs.incomings().size*5) val localCommit = actionsAlice2.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.CommitTx) assertEquals(localCommit.txid, alice1.commitments.active.first().localCommit.publishableTxs.commitTx.tx.txid) val claimMain = actionsAlice2.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.ClaimLocalDelayedOutputTx) @@ -1274,7 +1556,7 @@ class SpliceTestsCommon : LightningTestSuite() { // Alice detects that the remote force-close is not based on the latest funding transaction. val (alice2, actionsAlice2) = alice1.process(ChannelCommand.WatchReceived(WatchEventSpent(alice1.channelId, BITCOIN_FUNDING_SPENT, bobCommitTx))) assertIs(alice2.state) - assertEquals(actionsAlice2.size, 7) + assertEquals(actionsAlice2.size, 17) // Alice attempts to force-close and in parallel puts a watch on the remote commit. val localCommit = actionsAlice2.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.CommitTx) assertEquals(localCommit.txid, alice1.commitments.active.first().localCommit.publishableTxs.commitTx.tx.txid) @@ -1304,7 +1586,11 @@ class SpliceTestsCommon : LightningTestSuite() { // Alice's transactions confirm. val (alice4, actionsAlice4) = alice3.process(ChannelCommand.WatchReceived(WatchEventConfirmed(alice3.channelId, BITCOIN_TX_CONFIRMED(bobCommitTx), alice3.currentBlockHeight, 43, bobCommitTx))) - assertEquals(actionsAlice4.size, 1) + val failedOutgoingHtlcs = when (bobCommitTx.txid == alice3.commitments.latest.remoteCommit.txid) { + true -> 0 + else -> alice3.commitments.latest.remoteCommit.spec.htlcs.incomings().size + } + assertEquals(actionsAlice4.size, 1 + failedOutgoingHtlcs) actionsAlice4.has() val (alice5, actionsAlice5) = alice4.process(ChannelCommand.WatchReceived(WatchEventConfirmed(alice4.channelId, BITCOIN_TX_CONFIRMED(claimMainPenalty), alice4.currentBlockHeight, 44, claimMainPenalty))) assertEquals(actionsAlice5.size, 1) @@ -1325,6 +1611,73 @@ class SpliceTestsCommon : LightningTestSuite() { val spliceInit = actionsAlice2.findOutgoingMessage() return Triple(alice2, bob1, spliceInit) } + + data class TestHtlcs(val aliceToBob: List>, val bobToAlice: List>) + + private fun setupHtlcs(alice: LNChannel, bob: LNChannel): Triple, LNChannel, TestHtlcs> { + val (nodes1, preimage1, add1) = addHtlc(15_000_000.msat, alice, bob) + val (nodes2, preimage2, add2) = addHtlc(15_000_000.msat, nodes1.first, nodes1.second) + val (alice3, bob3) = crossSign(nodes2.first, nodes2.second) + val (nodes3, preimage3, add3) = addHtlc(20_000_000.msat, bob3, alice3) + val (nodes4, preimage4, add4) = addHtlc(15_000_000.msat, nodes3.first, nodes3.second) + val (bob5, alice5) = crossSign(nodes4.first, nodes4.second) + + assertIs(alice5.state) + assertEquals(1_000_000.sat, alice5.state.commitments.latest.capacity) + assertEquals(770_000_000.msat, alice5.state.commitments.latest.localCommit.spec.toLocal) + assertEquals(165_000_000.msat, alice5.state.commitments.latest.localCommit.spec.toRemote) + + val aliceToBob = listOf(Pair(preimage1, add1), Pair(preimage2, add2)) + val bobToAlice = listOf(Pair(preimage3, add3), Pair(preimage4, add4)) + return Triple(alice5, bob5, TestHtlcs(aliceToBob, bobToAlice)) + } + + private fun spliceFee(alice: LNChannel, capacity: Satoshi): Satoshi { + // The splice initiator always pays fees from their local balance; this reduces the funding amount. + assertIs(alice.state) + val fundingTx = alice.commitments.latest.localFundingStatus.signedTx!! + val expectedMiningFee = Transactions.weight2fee(FeeratePerKw(253.sat), fundingTx.weight()) + val actualMiningFee = capacity - alice.state.commitments.latest.capacity + // Fee computation is approximate (signature size isn't constant). + assertTrue(actualMiningFee >= 0.sat && abs(actualMiningFee.toLong() - expectedMiningFee.toLong()) < 100) + return actualMiningFee + } + + private fun checkPostSpliceState(alice: LNChannel, spliceOutFee: Satoshi) { + assertEquals(alice.state.commitments.latest.capacity, 950_000.sat - spliceOutFee) + assertEquals(alice.state.commitments.latest.localCommit.spec.toLocal, 750_000_000.msat - spliceOutFee.toMilliSatoshi() - 30_000_000.msat) + assertEquals(alice.state.commitments.latest.localCommit.spec.toRemote, 200_000_000.msat - 35_000_000.msat) + assertEquals(alice.state.commitments.latest.localCommit.spec.htlcs.incomings().map{ it.amountMsat }.sum(), 35_000_000.msat) + assertEquals(alice.state.commitments.latest.localCommit.spec.htlcs.outgoings().map{ it.amountMsat }.sum(), 30_000_000.msat) + } + + private fun resolveHtlcs(alice: LNChannel, bob: LNChannel, htlcs: TestHtlcs, spliceOutFee: Satoshi, commitmentsCount: Int): Pair, LNChannel> { + checkPostSpliceState(alice, spliceOutFee) + // resolve pre-splice HTLCs after splice + val (preimage1a, htlc1a) = htlcs.aliceToBob.first() + val (preimage2a, htlc2a) = htlcs.aliceToBob.last() + val (preimage1b, htlc1b) = htlcs.bobToAlice.first() + val (preimage2b, htlc2b) = htlcs.bobToAlice.last() + val nodes1 = fulfillHtlc(htlc1a.id, preimage1a, alice, bob) + val nodes2 = fulfillHtlc(htlc2a.id, preimage2a, nodes1.first, nodes1.second) + val nodes3 = fulfillHtlc(htlc1b.id, preimage1b, nodes2.second, nodes2.first) + val nodes4 = fulfillHtlc(htlc2b.id, preimage2b, nodes3.first, nodes3.second) + val nodes5 = crossSign(nodes4.first, nodes4.second, commitmentsCount) + val aliceFinal = nodes5.second.commitments.latest + val bobFinal = nodes5.first.commitments.latest + assertTrue(aliceFinal.localCommit.spec.htlcs.outgoings().isEmpty()) + assertTrue(bobFinal.remoteCommit.spec.htlcs.outgoings().isEmpty()) + assertTrue(aliceFinal.localCommit.spec.htlcs.outgoings().isEmpty()) + assertTrue(aliceFinal.remoteCommit.spec.htlcs.outgoings().isEmpty()) + + val settledHtlcs = 5_000_000.msat + assertEquals(950_000.sat - spliceOutFee, aliceFinal.capacity) + assertEquals(750_000_000.msat - spliceOutFee.toMilliSatoshi() + settledHtlcs, aliceFinal.localCommit.spec.toLocal) + assertEquals(200_000_000.msat - settledHtlcs, aliceFinal.localCommit.spec.toRemote) + + return Pair(nodes5.second, nodes5.first) + } + } }