Skip to content

Commit

Permalink
Send commit sigs for alternative feerates (#553)
Browse files Browse the repository at this point in the history
When the commitment transaction doesn't contain any HTLC, we send
additional signatures for alternative feerates. This lets the wallet
provider force-close with a more interesting feerate when the wallet
user disappears without closing their channels.
  • Loading branch information
t-bast authored Nov 3, 2023
1 parent 1e4aaad commit 7eb80e2
Show file tree
Hide file tree
Showing 10 changed files with 295 additions and 23 deletions.
39 changes: 38 additions & 1 deletion src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import fr.acinq.bitcoin.Crypto.sha256
import fr.acinq.lightning.CltvExpiryDelta
import fr.acinq.lightning.Feature
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.states.Channel
Expand Down Expand Up @@ -421,7 +422,18 @@ data class Commitment(
"built remote commit number=${remoteCommit.index + 1} toLocalMsat=${spec.toLocal.toLong()} toRemoteMsat=${spec.toRemote.toLong()} htlc_in=$htlcsIn htlc_out=$htlcsOut feeratePerKw=${spec.feerate} txId=${remoteCommitTx.tx.txid} fundingTxId=$fundingTxId"
}

val commitSig = CommitSig(params.channelId, sig, htlcSigs.toList())
val tlvs = buildSet {
if (spec.htlcs.isEmpty()) {
val alternativeSigs = Commitments.alternativeFeerates.map { feerate ->
val alternativeSpec = spec.copy(feerate = feerate)
val (alternativeRemoteCommitTx, _) = Commitments.makeRemoteTxs(channelKeys, commitTxNumber = remoteCommit.index + 1, params.localParams, params.remoteParams, fundingTxIndex = fundingTxIndex, remoteFundingPubKey = remoteFundingPubkey, commitInput, remotePerCommitmentPoint = remoteNextPerCommitmentPoint, alternativeSpec)
val alternativeSig = Transactions.sign(alternativeRemoteCommitTx, channelKeys.fundingKey(fundingTxIndex))
CommitSigTlv.AlternativeFeerateSig(feerate, alternativeSig)
}
add(CommitSigTlv.AlternativeFeerateSigs(alternativeSigs))
}
}
val commitSig = CommitSig(params.channelId, sig, htlcSigs.toList(), TlvStream(tlvs))
val commitment1 = copy(nextRemoteCommit = NextRemoteCommit(commitSig, RemoteCommit(remoteCommit.index + 1, spec, remoteCommitTx.tx.txid, remoteNextPerCommitmentPoint)))
return Pair(commitment1, commitSig)
}
Expand Down Expand Up @@ -933,6 +945,31 @@ data class Commitments(
const val HTLC_TIMEOUT_WEIGHT = 666
const val HTLC_SUCCESS_WEIGHT = 706

/**
* Alternative feerates at which we will sign commitment transactions that have no pending HTLCs.
* WARNING: never remove a feerate from this list, we can only add more, otherwise we will not be able to detect when our peer broadcasts the commit tx at the removed feerate.
*/
val alternativeFeerates = listOf(1.sat, 2.sat, 5.sat, 10.sat).map { FeeratePerKw(FeeratePerByte(it)) }

/**
* Our peer may publish an alternative version of their commitment using a different feerate.
* This function lists all the alternative commitments they have signatures for.
*/
fun alternativeFeerateCommits(commitments: Commitments, channelKeys: KeyManager.ChannelKeys): List<RemoteCommit> {
return buildList {
add(commitments.latest.remoteCommit)
commitments.latest.nextRemoteCommit?.let { add(it.commit) }
}.filter { remoteCommit ->
remoteCommit.spec.htlcs.isEmpty()
}.flatMap { remoteCommit ->
alternativeFeerates.map { feerate ->
val alternativeSpec = remoteCommit.spec.copy(feerate = feerate)
val (alternativeRemoteCommitTx, _) = makeRemoteTxs(channelKeys, remoteCommit.index, commitments.params.localParams, commitments.params.remoteParams, commitments.latest.fundingTxIndex, commitments.latest.remoteFundingPubkey, commitments.latest.commitInput, remoteCommit.remotePerCommitmentPoint, alternativeSpec)
RemoteCommit(remoteCommit.index, alternativeSpec, alternativeRemoteCommitTx.tx.txid, remoteCommit.remotePerCommitmentPoint)
}
}
}

fun makeLocalTxs(
channelKeys: KeyManager.ChannelKeys,
commitTxNumber: Long,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -849,7 +849,23 @@ data class InteractiveTxSigningSession(
remotePerCommitmentPoint = remotePerCommitmentPoint
).map { firstCommitTx ->
val localSigOfRemoteTx = Transactions.sign(firstCommitTx.remoteCommitTx, channelKeys.fundingKey(fundingTxIndex))
val commitSig = CommitSig(channelParams.channelId, localSigOfRemoteTx, listOf())
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 commitSig = CommitSig(channelParams.channelId, localSigOfRemoteTx, listOf(), TlvStream(CommitSigTlv.AlternativeFeerateSigs(alternativeSigs)))
val unsignedLocalCommit = UnsignedLocalCommit(localCommitmentIndex, firstCommitTx.localSpec, firstCommitTx.localCommitTx, listOf())
val remoteCommit = RemoteCommit(remoteCommitmentIndex, firstCommitTx.remoteSpec, firstCommitTx.remoteCommitTx.tx.txid, remotePerCommitmentPoint)
val signedFundingTx = sharedTx.sign(keyManager, fundingParams, channelParams.localParams, channelParams.remoteParams.nodeId)
Expand Down
54 changes: 39 additions & 15 deletions src/commonMain/kotlin/fr/acinq/lightning/channel/states/Channel.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
package fr.acinq.lightning.channel.states

import fr.acinq.bitcoin.*
import fr.acinq.lightning.*
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.bitcoin.PublicKey
import fr.acinq.bitcoin.Transaction
import fr.acinq.lightning.CltvExpiryDelta
import fr.acinq.lightning.Feature
import fr.acinq.lightning.NodeParams
import fr.acinq.lightning.SensitiveTaskEvents
import fr.acinq.lightning.blockchain.*
import fr.acinq.lightning.blockchain.fee.OnChainFeerates
import fr.acinq.lightning.channel.*
Expand All @@ -13,7 +19,7 @@ import fr.acinq.lightning.channel.Helpers.Closing.getRemotePerCommitmentSecret
import fr.acinq.lightning.crypto.KeyManager
import fr.acinq.lightning.db.ChannelClosingType
import fr.acinq.lightning.serialization.Encryption.from
import fr.acinq.lightning.transactions.Transactions.TransactionWithInputInfo.*
import fr.acinq.lightning.transactions.Transactions.TransactionWithInputInfo.ClosingTx
import fr.acinq.lightning.utils.*
import fr.acinq.lightning.wire.*

Expand Down Expand Up @@ -517,18 +523,36 @@ sealed class ChannelStateWithCommitments : PersistedChannelState() {
})
}
else -> {
logger.warning { "unrecognized tx=${tx.txid}" }
// This can happen if the user has two devices.
// - user creates a wallet on device #1
// - user restores the same wallet on device #2
// - user does a splice on device #2
// - user starts wallet on device #1
// The wallet on device #1 has a previous version of the channel, it is not aware of the splice tx. It won't be able
// to recognize the tx when the watcher notifies that the (old) funding tx was spent.
// However, there is a race with the reconnection logic, because then the device #1 will recover its latest state from the
// remote backup.
// So, the best thing to do here is to ignore the spending tx.
Pair(this@ChannelStateWithCommitments, listOf())
// Our peer may publish an alternative version of their commitment using a different feerate.
when (val remoteCommit = Commitments.alternativeFeerateCommits(commitments, channelKeys()).find { it.txid == tx.txid }) {
null -> {
logger.warning { "unrecognized tx=${tx.txid}" }
// This can happen if the user has two devices.
// - user creates a wallet on device #1
// - user restores the same wallet on device #2
// - user does a splice on device #2
// - user starts wallet on device #1
// The wallet on device #1 has a previous version of the channel, it is not aware of the splice tx. It won't be able
// to recognize the tx when the watcher notifies that the (old) funding tx was spent.
// However, there is a race with the reconnection logic, because then the device #1 will recover its latest state from the
// remote backup.
// So, the best thing to do here is to ignore the spending tx.
Pair(this@ChannelStateWithCommitments, listOf())
}
else -> {
logger.warning { "they published an alternative commitment with feerate=${remoteCommit.spec.feerate} txid=${tx.txid}" }
val remoteCommitPublished = claimRemoteCommitMainOutput(channelKeys(), commitments.params, tx, currentOnChainFeerates.claimMainFeerate)
val nextState = when (this@ChannelStateWithCommitments) {
is Closing -> this@ChannelStateWithCommitments.copy(remoteCommitPublished = remoteCommitPublished)
is Negotiating -> Closing(commitments, waitingSinceBlock = currentBlockHeight.toLong(), mutualCloseProposed = closingTxProposed.flatten().map { it.unsignedTx }, remoteCommitPublished = remoteCommitPublished)
else -> Closing(commitments, waitingSinceBlock = currentBlockHeight.toLong(), remoteCommitPublished = remoteCommitPublished)
}
return Pair(nextState, buildList {
add(ChannelAction.Storage.StoreState(nextState))
addAll(remoteCommitPublished.run { doPublish(channelId, staticParams.nodeParams.minDepthBlocks.toLong()) })
})
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@
JsonSerializers.CommitmentChangesSerializer::class,
JsonSerializers.LocalFundingStatusSerializer::class,
JsonSerializers.RemoteFundingStatusSerializer::class,
JsonSerializers.EncryptedChannelDataSerializer::class,
JsonSerializers.ShutdownSerializer::class,
JsonSerializers.ClosingSignedSerializer::class,
JsonSerializers.UpdateAddHtlcSerializer::class,
Expand All @@ -82,6 +81,8 @@
JsonSerializers.ClosingSignedTlvSerializer::class,
JsonSerializers.ChannelReestablishTlvSerializer::class,
JsonSerializers.ChannelReadyTlvSerializer::class,
JsonSerializers.CommitSigTlvAlternativeFeerateSigSerializer::class,
JsonSerializers.CommitSigTlvAlternativeFeerateSigsSerializer::class,
JsonSerializers.CommitSigTlvSerializer::class,
JsonSerializers.UUIDSerializer::class,
JsonSerializers.ClosingSerializer::class,
Expand Down Expand Up @@ -184,6 +185,7 @@ object JsonSerializers {
}
polymorphic(Tlv::class) {
subclass(ChannelReadyTlv.ShortChannelIdTlv::class, ChannelReadyTlvShortChannelIdTlvSerializer)
subclass(CommitSigTlv.AlternativeFeerateSigs::class, CommitSigTlvAlternativeFeerateSigsSerializer)
subclass(ShutdownTlv.ChannelData::class, ShutdownTlvChannelDataSerializer)
subclass(ClosingSignedTlv.FeeRange::class, ClosingSignedTlvFeeRangeSerializer)
}
Expand Down Expand Up @@ -486,6 +488,12 @@ object JsonSerializers {
@Serializer(forClass = ShutdownTlv::class)
object ShutdownTlvSerializer

@Serializer(forClass = CommitSigTlv.AlternativeFeerateSig::class)
object CommitSigTlvAlternativeFeerateSigSerializer

@Serializer(forClass = CommitSigTlv.AlternativeFeerateSigs::class)
object CommitSigTlvAlternativeFeerateSigsSerializer

@Serializer(forClass = CommitSigTlv::class)
object CommitSigTlvSerializer

Expand Down
32 changes: 32 additions & 0 deletions src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import fr.acinq.bitcoin.io.Output
import fr.acinq.lightning.Features
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.ShortChannelId
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
import fr.acinq.lightning.channel.ChannelType
import fr.acinq.lightning.channel.Origin
import fr.acinq.lightning.utils.*
Expand Down Expand Up @@ -172,6 +173,37 @@ sealed class CommitSigTlv : Tlv {
}
}

data class AlternativeFeerateSig(val feerate: FeeratePerKw, val sig: ByteVector64)

/**
* When there are no pending HTLCs, we provide a list of signatures for the commitment transaction signed at various feerates.
* This gives more options to the remote node to recover their funds if the user disappears without closing channels.
*/
data class AlternativeFeerateSigs(val sigs: List<AlternativeFeerateSig>) : CommitSigTlv() {
override val tag: Long get() = AlternativeFeerateSigs.tag
override fun write(out: Output) {
LightningCodecs.writeByte(sigs.size, out)
sigs.forEach {
LightningCodecs.writeU32(it.feerate.toLong().toInt(), out)
LightningCodecs.writeBytes(it.sig, out)
}
}

companion object : TlvValueReader<AlternativeFeerateSigs> {
const val tag: Long = 0x47010001
override fun read(input: Input): AlternativeFeerateSigs {
val count = LightningCodecs.byte(input)
val sigs = (0 until count).map {
AlternativeFeerateSig(
FeeratePerKw(LightningCodecs.u32(input).toLong().sat),
LightningCodecs.bytes(input, 64).toByteVector64()
)
}
return AlternativeFeerateSigs(sigs)
}
}
}

data class Batch(val size: Int) : CommitSigTlv() {
override val tag: Long get() = Batch.tag
override fun write(out: Output) = LightningCodecs.writeTU16(size, out)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,7 @@ data class CommitSig(
override val channelData: EncryptedChannelData get() = tlvStream.get<CommitSigTlv.ChannelData>()?.ecb ?: EncryptedChannelData.empty
override fun withNonEmptyChannelData(ecd: EncryptedChannelData): CommitSig = copy(tlvStream = tlvStream.addOrUpdate(CommitSigTlv.ChannelData(ecd)))

val alternativeFeerateSigs: List<CommitSigTlv.AlternativeFeerateSig> = tlvStream.get<CommitSigTlv.AlternativeFeerateSigs>()?.sigs ?: listOf()
val batchSize: Int = tlvStream.get<CommitSigTlv.Batch>()?.size ?: 1

override fun write(out: Output) {
Expand All @@ -1117,6 +1118,7 @@ data class CommitSig(
@Suppress("UNCHECKED_CAST")
val readers = mapOf(
CommitSigTlv.ChannelData.tag to CommitSigTlv.ChannelData.Companion as TlvValueReader<CommitSigTlv>,
CommitSigTlv.AlternativeFeerateSigs.tag to CommitSigTlv.AlternativeFeerateSigs.Companion as TlvValueReader<CommitSigTlv>,
CommitSigTlv.Batch.tag to CommitSigTlv.Batch.Companion as TlvValueReader<CommitSigTlv>,
)

Expand Down
24 changes: 24 additions & 0 deletions src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,30 @@ object TestsHelper {
return s1 to remoteCommitPublished
}

fun useAlternativeCommitSig(s: LNChannel<ChannelState>, commitment: Commitment, alternative: CommitSigTlv.AlternativeFeerateSig): Transaction {
val channelKeys = s.commitments.params.localParams.channelKeys(s.ctx.keyManager)
val alternativeSpec = commitment.localCommit.spec.copy(feerate = alternative.feerate)
val fundingTxIndex = commitment.fundingTxIndex
val commitInput = commitment.commitInput
val remoteFundingPubKey = commitment.remoteFundingPubkey
val localPerCommitmentPoint = channelKeys.commitmentPoint(commitment.localCommit.index)
val (localCommitTx, _) = Commitments.makeLocalTxs(
channelKeys,
commitment.localCommit.index,
s.commitments.params.localParams,
s.commitments.params.remoteParams,
fundingTxIndex,
remoteFundingPubKey,
commitInput,
localPerCommitmentPoint,
alternativeSpec
)
val localSig = Transactions.sign(localCommitTx, channelKeys.fundingKey(fundingTxIndex))
val signedCommitTx = Transactions.addSigs(localCommitTx, channelKeys.fundingPubKey(fundingTxIndex), remoteFundingPubKey, localSig, alternative.sig)
assertTrue(Transactions.checkSpendable(signedCommitTx).isSuccess)
return signedCommitTx.tx
}

fun signAndRevack(alice: LNChannel<ChannelState>, bob: LNChannel<ChannelState>): Pair<LNChannel<ChannelState>, LNChannel<ChannelState>> {
val (alice1, actions1) = alice.process(ChannelCommand.Commitment.Sign)
val commitSig = actions1.findOutgoingMessage<CommitSig>()
Expand Down
Loading

0 comments on commit 7eb80e2

Please sign in to comment.