Skip to content

Commit 371ac59

Browse files
committed
Replace pay_to_open with will_add_htlc
We replace the previous pay-to-open protocol with a new protocol that only relies on liquidity ads for paying fees. We simply transmit HTLCs that cannot be relayed on existing channels with a new message called `will_add_htlc` that contains all the HTLC data. The recipient can verify that the HTLC that would match this promise is valid, and if it wishes to accept that payment, it can trigger a channel open or a splice to purchase the required inbound liquidity. Once that transaction completes, the sender will relay HTLCs matching the proposed `will_add_htlc`, which completes the payment. If the fees for the inbound liquidity purchase couldn't be paid from the previous channel balance, they can be taken from the HTLCs relayed after the funding transaction. When that happens, one side needs to trust that the other will comply. Each side can independently configure the options they're comfortable with, depending on whether they trust their peer or not.
1 parent 86253a4 commit 371ac59

29 files changed

+1475
-919
lines changed

src/commonMain/kotlin/fr/acinq/lightning/Features.kt

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ sealed class Feature {
126126
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node)
127127
}
128128

129+
@Serializable
130+
object Quiescence : Feature() {
131+
override val rfcName get() = "option_quiescence"
132+
override val mandatory get() = 34
133+
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node)
134+
}
135+
129136
@Serializable
130137
object ChannelType : Feature() {
131138
override val rfcName get() = "option_channel_type"
@@ -185,15 +192,15 @@ sealed class Feature {
185192
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node)
186193
}
187194

188-
/** This feature bit should be activated when a node accepts on-the-fly channel creation. */
195+
/** DEPRECATED: this feature bit was used for the legacy pay-to-open protocol. */
189196
@Serializable
190197
object PayToOpenClient : Feature() {
191198
override val rfcName get() = "pay_to_open_client"
192199
override val mandatory get() = 136
193200
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init)
194201
}
195202

196-
/** This feature bit should be activated when a node supports opening channels on-the-fly when liquidity is missing to receive a payment. */
203+
/** DEPRECATED: this feature bit was used for the legacy pay-to-open protocol. */
197204
@Serializable
198205
object PayToOpenProvider : Feature() {
199206
override val rfcName get() = "pay_to_open_provider"
@@ -250,9 +257,9 @@ sealed class Feature {
250257
}
251258

252259
@Serializable
253-
object Quiescence : Feature() {
254-
override val rfcName get() = "option_quiescence"
255-
override val mandatory get() = 34
260+
object OnTheFlyFunding : Feature() {
261+
override val rfcName get() = "on_the_fly_funding"
262+
override val mandatory get() = 560
256263
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node)
257264
}
258265

@@ -322,6 +329,7 @@ data class Features(val activated: Map<Feature, FeatureSupport>, val unknown: Se
322329
Feature.RouteBlinding,
323330
Feature.ShutdownAnySegwit,
324331
Feature.DualFunding,
332+
Feature.Quiescence,
325333
Feature.ChannelType,
326334
Feature.PaymentMetadata,
327335
Feature.TrampolinePayment,
@@ -337,7 +345,7 @@ data class Features(val activated: Map<Feature, FeatureSupport>, val unknown: Se
337345
Feature.ChannelBackupClient,
338346
Feature.ChannelBackupProvider,
339347
Feature.ExperimentalSplice,
340-
Feature.Quiescence
348+
Feature.OnTheFlyFunding
341349
)
342350

343351
operator fun invoke(bytes: ByteVector): Features = invoke(bytes.toByteArray())
@@ -369,7 +377,8 @@ data class Features(val activated: Map<Feature, FeatureSupport>, val unknown: Se
369377
Feature.BasicMultiPartPayment to listOf(Feature.PaymentSecret),
370378
Feature.AnchorOutputs to listOf(Feature.StaticRemoteKey),
371379
Feature.TrampolinePayment to listOf(Feature.PaymentSecret),
372-
Feature.ExperimentalTrampolinePayment to listOf(Feature.PaymentSecret)
380+
Feature.ExperimentalTrampolinePayment to listOf(Feature.PaymentSecret),
381+
Feature.OnTheFlyFunding to listOf(Feature.ExperimentalSplice)
373382
)
374383

375384
class FeatureException(message: String) : IllegalArgumentException(message)

src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ sealed interface LiquidityEvents : NodeEvents {
4848
data class OverAbsoluteFee(val maxAbsoluteFee: Satoshi) : TooExpensive()
4949
data class OverRelativeFee(val maxRelativeFeeBasisPoints: Int) : TooExpensive()
5050
}
51-
data object ChannelInitializing : Reason()
51+
data object ChannelFundingInProgress : Reason()
52+
data object NoMatchingFundingRate : Reason()
53+
data class MissingOffChainAmountTooLow(val missingOffChainAmount: MilliSatoshi) : Reason()
54+
data class TooManyParts(val parts: Int) : Reason()
5255
}
5356
}
5457
data class Accepted(override val amount: MilliSatoshi, override val fee: MilliSatoshi, override val source: Source) : LiquidityEvents

src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ data class NodeParams(
176176
require(!features.hasFeature(Feature.ZeroConfChannels)) { "${Feature.ZeroConfChannels.rfcName} has been deprecated: use the zeroConfPeers whitelist instead" }
177177
require(!features.hasFeature(Feature.TrustedSwapInClient)) { "${Feature.TrustedSwapInClient.rfcName} has been deprecated" }
178178
require(!features.hasFeature(Feature.TrustedSwapInProvider)) { "${Feature.TrustedSwapInProvider.rfcName} has been deprecated" }
179+
require(!features.hasFeature(Feature.PayToOpenClient)) { "${Feature.PayToOpenClient.rfcName} has been deprecated" }
180+
require(!features.hasFeature(Feature.PayToOpenProvider)) { "${Feature.PayToOpenProvider.rfcName} has been deprecated" }
179181
Features.validateFeatureGraph(features)
180182
}
181183

@@ -197,15 +199,15 @@ data class NodeParams(
197199
Feature.RouteBlinding to FeatureSupport.Optional,
198200
Feature.DualFunding to FeatureSupport.Mandatory,
199201
Feature.ShutdownAnySegwit to FeatureSupport.Mandatory,
202+
Feature.Quiescence to FeatureSupport.Mandatory,
200203
Feature.ChannelType to FeatureSupport.Mandatory,
201204
Feature.PaymentMetadata to FeatureSupport.Optional,
202205
Feature.ExperimentalTrampolinePayment to FeatureSupport.Optional,
203206
Feature.ZeroReserveChannels to FeatureSupport.Optional,
204207
Feature.WakeUpNotificationClient to FeatureSupport.Optional,
205-
Feature.PayToOpenClient to FeatureSupport.Optional,
206208
Feature.ChannelBackupClient to FeatureSupport.Optional,
207209
Feature.ExperimentalSplice to FeatureSupport.Optional,
208-
Feature.Quiescence to FeatureSupport.Mandatory
210+
Feature.OnTheFlyFunding to FeatureSupport.Optional,
209211
),
210212
dustLimit = 546.sat,
211213
maxRemoteDustLimit = 600.sat,

src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ data class ToSelfDelayTooHigh (override val channelId: Byte
2828
data class MissingLiquidityAds (override val channelId: ByteVector32) : ChannelException(channelId, "liquidity ads field is missing")
2929
data class InvalidLiquidityAdsSig (override val channelId: ByteVector32) : ChannelException(channelId, "liquidity ads signature is invalid")
3030
data class InvalidLiquidityAdsAmount (override val channelId: ByteVector32, val proposed: Satoshi, val min: Satoshi) : ChannelException(channelId, "liquidity ads funding amount is too low (expected at least $min, got $proposed)")
31+
data class UnexpectedLiquidityAdsFundingFee (override val channelId: ByteVector32, val fundingTxId: TxId) : ChannelException(channelId, "unexpected liquidity ads funding fee for txId=$fundingTxId (transaction not found)")
32+
data class InvalidLiquidityAdsFundingFee (override val channelId: ByteVector32, val fundingTxId: TxId, val paymentHash: ByteVector32, val expected: Satoshi, val proposed: MilliSatoshi) : ChannelException(channelId, "invalid liquidity ads funding fee for txId=$fundingTxId and paymentHash=$paymentHash (expected $expected, got $proposed)")
3133
data class ChannelFundingError (override val channelId: ByteVector32) : ChannelException(channelId, "channel funding error")
3234
data class RbfAttemptAborted (override val channelId: ByteVector32) : ChannelException(channelId, "rbf attempt aborted")
3335
data class SpliceAborted (override val channelId: ByteVector32) : ChannelException(channelId, "splice aborted")

src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,17 @@ import fr.acinq.bitcoin.Script.tail
55
import fr.acinq.bitcoin.crypto.musig2.IndividualNonce
66
import fr.acinq.bitcoin.crypto.musig2.Musig2
77
import fr.acinq.bitcoin.crypto.musig2.SecretNonce
8-
import fr.acinq.bitcoin.utils.getOrDefault
9-
import fr.acinq.lightning.Lightning.randomBytes32
108
import fr.acinq.bitcoin.utils.Either
119
import fr.acinq.bitcoin.utils.Try
10+
import fr.acinq.bitcoin.utils.getOrDefault
1211
import fr.acinq.bitcoin.utils.runTrying
12+
import fr.acinq.lightning.Lightning.randomBytes32
1313
import fr.acinq.lightning.MilliSatoshi
1414
import fr.acinq.lightning.blockchain.electrum.WalletState
1515
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
1616
import fr.acinq.lightning.crypto.Bolt3Derivation.deriveForCommitment
1717
import fr.acinq.lightning.crypto.KeyManager
18-
import fr.acinq.lightning.logging.*
19-
import fr.acinq.lightning.transactions.CommitmentSpec
20-
import fr.acinq.lightning.transactions.DirectedHtlc
21-
import fr.acinq.lightning.transactions.Scripts
22-
import fr.acinq.lightning.transactions.SwapInProtocol
23-
import fr.acinq.lightning.transactions.Transactions
18+
import fr.acinq.lightning.logging.MDCLogger
2419
import fr.acinq.lightning.transactions.*
2520
import fr.acinq.lightning.utils.*
2621
import fr.acinq.lightning.wire.*
@@ -227,7 +222,6 @@ sealed class FundingContributionFailure {
227222
data class InputBelowDust(val txId: TxId, val outputIndex: Int, val amount: Satoshi, val dustLimit: Satoshi) : FundingContributionFailure() { override fun toString(): String = "invalid input $txId:$outputIndex (below dust: amount=$amount, dust=$dustLimit)" }
228223
data class InputTxTooLarge(val tx: Transaction) : FundingContributionFailure() { override fun toString(): String = "invalid input tx ${tx.txid} (too large)" }
229224
data class NotEnoughFunding(val fundingAmount: Satoshi, val nonFundingAmount: Satoshi, val providedAmount: Satoshi) : FundingContributionFailure() { override fun toString(): String = "not enough funds provided (expected at least $fundingAmount + $nonFundingAmount, got $providedAmount)" }
230-
data class NotEnoughFees(val currentFees: Satoshi, val expectedFees: Satoshi) : FundingContributionFailure() { override fun toString(): String = "not enough funds to pay fees (expected at least $expectedFees, got $currentFees)" }
231225
data class InvalidFundingBalances(val fundingAmount: Satoshi, val localBalance: MilliSatoshi, val remoteBalance: MilliSatoshi) : FundingContributionFailure() { override fun toString(): String = "invalid balances funding_amount=$fundingAmount local=$localBalance remote=$remoteBalance" }
232226
// @formatter:on
233227
}
@@ -239,7 +233,14 @@ data class FundingContributions(val inputs: List<InteractiveTxInput.Outgoing>, v
239233
fun computeSpliceContribution(isInitiator: Boolean, commitment: Commitment, walletInputs: List<WalletState.Utxo>, localOutputs: List<TxOut>, targetFeerate: FeeratePerKw): Satoshi {
240234
val weight = computeWeightPaid(isInitiator, commitment, walletInputs, localOutputs)
241235
val fees = Transactions.weight2fee(targetFeerate, weight)
242-
return walletInputs.map { it.amount }.sum() - localOutputs.map { it.amount }.sum() - fees
236+
return when {
237+
// When buying inbound liquidity, we may not have enough funds in our current balance to pay on-chain fees.
238+
// The maximum amount we can use for on-chain fees is our current balance, which is fine because:
239+
// - this will simply result in a splice transaction with a lower feerate than expected
240+
// - liquidity fees will be paid later from future HTLCs relayed to us
241+
walletInputs.isEmpty() && localOutputs.isEmpty() -> -(fees.min(commitment.localCommit.spec.toLocal.truncateToSatoshi()))
242+
else -> walletInputs.map { it.amount }.sum() - localOutputs.map { it.amount }.sum() - fees
243+
}
243244
}
244245

245246
/**
@@ -276,27 +277,19 @@ data class FundingContributions(val inputs: List<InteractiveTxInput.Outgoing>, v
276277
return Either.Left(FundingContributionFailure.NotEnoughFunding(params.localContribution, localOutputs.map { it.amount }.sum(), totalAmountIn))
277278
}
278279

279-
// We compute the fees that we should pay in the shared transaction.
280-
val fundingPubkeyScript = params.fundingPubkeyScript(channelKeys)
281-
val weightWithoutChange = computeWeightPaid(params.isInitiator, sharedUtxo?.first, fundingPubkeyScript, walletInputs, localOutputs)
282-
val weightWithChange = computeWeightPaid(params.isInitiator, sharedUtxo?.first, fundingPubkeyScript, walletInputs, localOutputs + listOf(TxOut(0.sat, Script.pay2wpkh(Transactions.PlaceHolderPubKey))))
283-
val feesWithoutChange = totalAmountIn - totalAmountOut
284-
// If we're not the initiator, we don't return an error when we're unable to meet the desired feerate.
285-
if (params.isInitiator && feesWithoutChange < Transactions.weight2fee(params.targetFeerate, weightWithoutChange)) {
286-
return Either.Left(FundingContributionFailure.NotEnoughFees(feesWithoutChange, Transactions.weight2fee(params.targetFeerate, weightWithoutChange)))
287-
}
288-
289280
val nextLocalBalance = (sharedUtxo?.second?.toLocal ?: 0.msat) + params.localContribution.toMilliSatoshi()
290281
val nextRemoteBalance = (sharedUtxo?.second?.toRemote ?: 0.msat) + params.remoteContribution.toMilliSatoshi()
291282
if (nextLocalBalance < 0.msat || nextRemoteBalance < 0.msat) {
292283
return Either.Left(FundingContributionFailure.InvalidFundingBalances(params.fundingAmount, nextLocalBalance, nextRemoteBalance))
293284
}
294285

286+
val fundingPubkeyScript = params.fundingPubkeyScript(channelKeys)
295287
val sharedOutput = listOf(InteractiveTxOutput.Shared(0, fundingPubkeyScript, nextLocalBalance, nextRemoteBalance, sharedUtxo?.second?.toHtlcs ?: 0.msat))
296288
val nonChangeOutputs = localOutputs.map { o -> InteractiveTxOutput.Local.NonChange(0, o.amount, o.publicKeyScript) }
297289
val changeOutput = when (changePubKey) {
298290
null -> listOf()
299291
else -> {
292+
val weightWithChange = computeWeightPaid(params.isInitiator, sharedUtxo?.first, fundingPubkeyScript, walletInputs, localOutputs + listOf(TxOut(0.sat, Script.pay2wpkh(Transactions.PlaceHolderPubKey))))
300293
val changeAmount = totalAmountIn - totalAmountOut - Transactions.weight2fee(params.targetFeerate, weightWithChange)
301294
if (params.dustLimit <= changeAmount) {
302295
listOf(InteractiveTxOutput.Local.Change(0, changeAmount, Script.write(Script.pay2wpkh(changePubKey)).byteVector()))
@@ -940,8 +933,10 @@ data class InteractiveTxSession(
940933
return InteractiveTxSessionAction.InvalidTxFeerate(fundingParams.channelId, tx.txid, fundingParams.targetFeerate, nextFeerate)
941934
}
942935
} else {
936+
// We allow the feerate to be lower than requested: when using on-the-fly liquidity, we may not be able to contribute
937+
// as much as we expected, but that's fine because we instead overshoot the feerate and pays liquidity fees accordingly.
943938
val minimumFee = Transactions.weight2fee(fundingParams.targetFeerate, tx.weight())
944-
if (sharedTx.fees < minimumFee) {
939+
if (sharedTx.fees < minimumFee * 0.5) {
945940
return InteractiveTxSessionAction.InvalidTxFeerate(fundingParams.channelId, tx.txid, fundingParams.targetFeerate, Transactions.fee2rate(sharedTx.fees, tx.weight()))
946941
}
947942
}

src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,11 @@ data class Normal(
409409
val missing = spliceStatus.command.requestRemoteFunding?.let { r -> r.fees(spliceStatus.command.feerate).total - parentCommitment.localCommit.spec.toLocal.truncateToSatoshi() }
410410
logger.warning { "cannot do splice: balance is too low to pay for inbound liquidity (missing=$missing)" }
411411
spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds)
412-
Pair(this@Normal, emptyList())
412+
val actions = buildList {
413+
add(ChannelAction.Message.Send(Warning(channelId, InvalidSpliceRequest(channelId).message)))
414+
add(ChannelAction.Disconnect)
415+
}
416+
Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions)
413417
} else {
414418
val spliceInit = SpliceInit(
415419
channelId,
@@ -768,6 +772,17 @@ data class Normal(
768772
Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions)
769773
}
770774
}
775+
is CancelOnTheFlyFunding -> when (spliceStatus) {
776+
is SpliceStatus.Requested -> {
777+
logger.info { "our peer rejected our on-the-fly splice request: ascii='${cmd.message.toAscii()}'" }
778+
spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.AbortedByPeer(cmd.message.toAscii()))
779+
Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), endQuiescence())
780+
}
781+
else -> {
782+
logger.warning { "received unexpected cancel_on_the_fly_funding (spliceStatus=${spliceStatus::class.simpleName}, message='${cmd.message.toAscii()}')" }
783+
Pair(this@Normal, listOf(ChannelAction.Disconnect))
784+
}
785+
}
771786
is SpliceLocked -> {
772787
when (val res = commitments.run { updateRemoteFundingStatus(cmd.message.fundingTxId) }) {
773788
is Either.Left -> Pair(this@Normal, emptyList())

src/commonMain/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannel.kt

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,7 @@ import fr.acinq.lightning.ChannelEvents
66
import fr.acinq.lightning.channel.*
77
import fr.acinq.lightning.channel.Helpers.Funding.computeChannelId
88
import fr.acinq.lightning.utils.msat
9-
import fr.acinq.lightning.wire.AcceptDualFundedChannel
10-
import fr.acinq.lightning.wire.Error
11-
import fr.acinq.lightning.wire.LiquidityAds
12-
import fr.acinq.lightning.wire.OpenDualFundedChannel
9+
import fr.acinq.lightning.wire.*
1310

1411
/*
1512
* We initiated a channel open and are waiting for our peer to accept it.
@@ -123,6 +120,11 @@ data class WaitForAcceptChannel(
123120
}
124121
}
125122
}
123+
is CancelOnTheFlyFunding -> {
124+
// Our peer won't accept this on-the-fly funding attempt: they probably already failed the corresponding HTLCs.
125+
logger.warning { "on-the-fly funding was rejected by our peer: ${cmd.message.toAscii()}" }
126+
Pair(Aborted, listOf())
127+
}
126128
is Error -> handleRemoteError(cmd.message)
127129
else -> unhandled(cmd)
128130
}

0 commit comments

Comments
 (0)