Skip to content

Commit e6c1974

Browse files
authored
Fix min_final_expiry_delta failures for on-the-fly funding (#714)
When using on-the-fly funding, we have to create and publish an on-chain transaction after accepting `will_add_htlc`, before we can relay the matching HTLCs. It may take a few blocks before the transaction is published (or even confirmed), which means the HTLCs will be held and relayed after a delay. This can create an issue when there isn't enough margin in the HTLC's expiry: when we receive `will_add_htlc`, its expiry is below `currentBlockHeight + minFinalExpiryDelta`, so we accept the on-the-fly funding proposal, but when the HTLC is actually relayed a few blocks later, the previous condition isn't true anymore so we reject the HTLC and thus aren't paying the LSP if `from_future_htlc` was used. The BOLTs recommend that payers add an additional `cltv_expiry_delta` of their own to the current block height when sending payments: Phoenix correctly does that and adds at least 72 blocks, but there are other implementations that only add a couple of blocks. We thus relax this condition when receiving on-the-fly HTLCs: as long as the remaining expiry delta is greater than twice our `fulfill_safety_before_timeout` parameter, we have enough time to publish a force close and claim funds on-chain. We use this condition when evaluating whether we accept HTLCs to fix this issue. We also increase all of our default expiry parameters (which is a sane thing to do even outside of the context of this issue).
1 parent ea9c759 commit e6c1974

File tree

10 files changed

+117
-56
lines changed

10 files changed

+117
-56
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ data class CltvExpiryDelta(private val underlying: Int) : Comparable<CltvExpiryD
4040
operator fun plus(other: Int): CltvExpiryDelta = CltvExpiryDelta(underlying + other)
4141
operator fun plus(other: CltvExpiryDelta): CltvExpiryDelta = CltvExpiryDelta(underlying + other.underlying)
4242
operator fun minus(other: CltvExpiryDelta): CltvExpiryDelta = CltvExpiryDelta(underlying - other.underlying)
43+
operator fun times(m: Int): CltvExpiryDelta = CltvExpiryDelta(underlying * m)
4344
override fun compareTo(other: CltvExpiryDelta): Int = underlying.compareTo(other.underlying)
45+
fun min(other: CltvExpiryDelta): CltvExpiryDelta = if (this < other) this else other
4446
fun toInt(): Int = underlying
4547
fun toLong(): Long = underlying.toLong()
4648
override fun toString(): String = "CltvExpiryDelta($underlying)"

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

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,10 @@ import fr.acinq.lightning.blockchain.fee.FeerateTolerance
77
import fr.acinq.lightning.blockchain.fee.OnChainFeeConf
88
import fr.acinq.lightning.crypto.KeyManager
99
import fr.acinq.lightning.logging.LoggerFactory
10-
import fr.acinq.lightning.payment.Bolt11Invoice
1110
import fr.acinq.lightning.payment.LiquidityPolicy
1211
import fr.acinq.lightning.utils.msat
1312
import fr.acinq.lightning.utils.sat
1413
import fr.acinq.lightning.utils.toMilliSatoshi
15-
import fr.acinq.lightning.wire.LiquidityAds
1614
import fr.acinq.lightning.wire.OfferTypes
1715
import io.ktor.utils.io.charsets.*
1816
import io.ktor.utils.io.core.*
@@ -21,6 +19,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
2119
import kotlinx.coroutines.flow.SharedFlow
2220
import kotlinx.coroutines.flow.asSharedFlow
2321
import kotlin.time.Duration
22+
import kotlin.time.Duration.Companion.hours
2423
import kotlin.time.Duration.Companion.minutes
2524
import kotlin.time.Duration.Companion.seconds
2625

@@ -81,8 +80,11 @@ data class WalletParams(
8180
/**
8281
* When sending a payment, if the expiry used for the last node is very close to the current block height,
8382
* it lets intermediate nodes figure out their position in the route. To protect against this, a random
84-
* delta between min and max will be added to the current block height, which makes it look like there
85-
* are more hops after the final node.
83+
* number of blocks between min and max will be added to the current block height, before the `minFinalExpiryDelta`
84+
* requested by the recipient, which makes it look like there are more hops after the final node.
85+
*
86+
* The overall cltv expiry delta for an outgoing trampoline payment will thus be:
87+
* `cltvExpiryDelta` = `random(min, max)` + `minFinalExpiryDelta` + `TrampolineFees.cltvExpiryDelta`
8688
*/
8789
data class RecipientCltvExpiryParams(val min: CltvExpiryDelta, val max: CltvExpiryDelta) {
8890
fun computeFinalExpiry(currentBlockHeight: Int, minFinalExpiryDelta: CltvExpiryDelta): CltvExpiry {
@@ -105,9 +107,12 @@ data class RecipientCltvExpiryParams(val min: CltvExpiryDelta, val max: CltvExpi
105107
* @param maxHtlcValueInFlightMsat cap on the total value of pending HTLCs in a channel: this lets us limit our exposure to HTLCs risk.
106108
* @param maxAcceptedHtlcs cap on the number of pending HTLCs in a channel: this lets us limit our exposure to HTLCs risk.
107109
* @param expiryDeltaBlocks cltv-expiry-delta used in our channel_update: since our channels are private and we don't relay payments, this will be basically ignored.
110+
* @param minFinalCltvExpiryDelta cltv-expiry-delta that we require when receiving a payment.
108111
* @param fulfillSafetyBeforeTimeoutBlocks number of blocks necessary to react to a malicious peer that doesn't acknowledge and sign our HTLC preimages.
109112
* @param checkHtlcTimeoutAfterStartupDelay delay before we check for timed out HTLCs in our channels after a wallet restart.
110-
* @param htlcMinimum minimum accepted htlc value.
113+
* @param bolt11InvoiceExpiry duration for which bolt11 invoices that we create are valid.
114+
* @param bolt12InvoiceExpiry duration for which bolt12 invoices that we create are valid.
115+
* @param htlcMinimum minimum accepted HTLC value.
111116
* @param toRemoteDelayBlocks number of blocks our peer will have to wait before they get their main output back in case they force-close a channel.
112117
* @param maxToLocalDelayBlocks maximum number of blocks we will have to wait before we get our main output back in case we force-close a channel.
113118
* @param minDepthBlocks minimum depth of a transaction before we consider it safely confirmed.
@@ -116,14 +121,11 @@ data class RecipientCltvExpiryParams(val min: CltvExpiryDelta, val max: CltvExpi
116121
* @param pingInterval delay between ping messages.
117122
* @param initialRandomReconnectDelay delay before which we reconnect to our peers (will be randomized based on this value).
118123
* @param maxReconnectInterval maximum delay between reconnection attempts.
119-
* @param mppAggregationWindow amount of time we will wait to receive all parts of a multi-part payment.
124+
* @param mppAggregationWindow amount of time we will wait to receive all parts of a multipart payment.
120125
* @param maxPaymentAttempts maximum number of retries when attempting an outgoing payment.
121126
* @param paymentRecipientExpiryParams configure the expiry delta used for the final node when sending payments.
122127
* @param zeroConfPeers list of peers with whom we use zero-conf (note that this is a strong trust assumption).
123128
* @param liquidityPolicy fee policy for liquidity events, can be modified at any time.
124-
* @param minFinalCltvExpiryDelta cltv-expiry-delta that we require when receiving a payment.
125-
* @param maxFinalCltvExpiryDelta maximum cltv-expiry-delta that we accept when receiving a payment.
126-
* @param bolt12invoiceExpiry duration for which bolt12 invoices that we create are valid.
127129
*/
128130
data class NodeParams(
129131
val loggerFactory: LoggerFactory,
@@ -136,9 +138,12 @@ data class NodeParams(
136138
val maxHtlcValueInFlightMsat: Long,
137139
val maxAcceptedHtlcs: Int,
138140
val expiryDeltaBlocks: CltvExpiryDelta,
141+
val minFinalCltvExpiryDelta: CltvExpiryDelta,
139142
val fulfillSafetyBeforeTimeoutBlocks: CltvExpiryDelta,
140143
val checkHtlcTimeoutAfterStartupDelay: Duration,
141144
val checkHtlcTimeoutInterval: Duration,
145+
val bolt11InvoiceExpiry: Duration,
146+
val bolt12InvoiceExpiry: Duration,
142147
val htlcMinimum: MilliSatoshi,
143148
val toRemoteDelayBlocks: CltvExpiryDelta,
144149
val maxToLocalDelayBlocks: CltvExpiryDelta,
@@ -153,9 +158,6 @@ data class NodeParams(
153158
val paymentRecipientExpiryParams: RecipientCltvExpiryParams,
154159
val zeroConfPeers: Set<PublicKey>,
155160
val liquidityPolicy: MutableStateFlow<LiquidityPolicy>,
156-
val minFinalCltvExpiryDelta: CltvExpiryDelta,
157-
val maxFinalCltvExpiryDelta: CltvExpiryDelta,
158-
val bolt12invoiceExpiry: Duration,
159161
) {
160162
val nodePrivateKey get() = keyManager.nodeKeys.nodeKey.privateKey
161163
val nodeId get() = keyManager.nodeKeys.nodeKey.publicKey
@@ -165,6 +167,7 @@ data class NodeParams(
165167
val nodeEvents: SharedFlow<NodeEvents> get() = _nodeEvents.asSharedFlow()
166168

167169
init {
170+
// Verify required features are set and obsolete features aren't set.
168171
require(features.hasFeature(Feature.VariableLengthOnion, FeatureSupport.Mandatory)) { "${Feature.VariableLengthOnion.rfcName} should be mandatory" }
169172
require(features.hasFeature(Feature.PaymentSecret, FeatureSupport.Mandatory)) { "${Feature.PaymentSecret.rfcName} should be mandatory" }
170173
require(features.hasFeature(Feature.ChannelType, FeatureSupport.Mandatory)) { "${Feature.ChannelType.rfcName} should be mandatory" }
@@ -176,6 +179,8 @@ data class NodeParams(
176179
require(!features.hasFeature(Feature.PayToOpenClient)) { "${Feature.PayToOpenClient.rfcName} has been deprecated" }
177180
require(!features.hasFeature(Feature.PayToOpenProvider)) { "${Feature.PayToOpenProvider.rfcName} has been deprecated" }
178181
Features.validateFeatureGraph(features)
182+
// Verify expiry parameters are consistent with each other.
183+
require((fulfillSafetyBeforeTimeoutBlocks * 2) < minFinalCltvExpiryDelta) { "min_final_expiry_delta must be at least twice as long as fulfill_safety_before_timeout_blocks" }
179184
}
180185

181186
/**
@@ -217,9 +222,15 @@ data class NodeParams(
217222
maxHtlcValueInFlightMsat = 20_000_000_000L,
218223
maxAcceptedHtlcs = 6,
219224
expiryDeltaBlocks = CltvExpiryDelta(144),
220-
fulfillSafetyBeforeTimeoutBlocks = CltvExpiryDelta(6),
225+
// We use a long expiry delta here for a few reasons:
226+
// - we want to ensure we're able to get HTLC-success txs confirmed if our peer ignores our preimage
227+
// - we may be offline for a while, so we want our peer to be able to hold HTLCs and forward them when we come back online
228+
minFinalCltvExpiryDelta = CltvExpiryDelta(144),
229+
fulfillSafetyBeforeTimeoutBlocks = CltvExpiryDelta(12),
221230
checkHtlcTimeoutAfterStartupDelay = 30.seconds,
222231
checkHtlcTimeoutInterval = 10.seconds,
232+
bolt11InvoiceExpiry = 24.hours,
233+
bolt12InvoiceExpiry = 24.hours,
223234
htlcMinimum = 1000.msat,
224235
minDepthBlocks = 3,
225236
toRemoteDelayBlocks = CltvExpiryDelta(2016),
@@ -232,7 +243,7 @@ data class NodeParams(
232243
mppAggregationWindow = 60.seconds,
233244
maxPaymentAttempts = 5,
234245
zeroConfPeers = emptySet(),
235-
paymentRecipientExpiryParams = RecipientCltvExpiryParams(CltvExpiryDelta(75), CltvExpiryDelta(200)),
246+
paymentRecipientExpiryParams = RecipientCltvExpiryParams(CltvExpiryDelta(72), CltvExpiryDelta(144)),
236247
liquidityPolicy = MutableStateFlow<LiquidityPolicy>(
237248
LiquidityPolicy.Auto(
238249
inboundLiquidityTarget = null,
@@ -243,9 +254,6 @@ data class NodeParams(
243254
maxAllowedFeeCredit = 0.msat
244255
)
245256
),
246-
minFinalCltvExpiryDelta = Bolt11Invoice.DEFAULT_MIN_FINAL_EXPIRY_DELTA,
247-
maxFinalCltvExpiryDelta = CltvExpiryDelta(360),
248-
bolt12invoiceExpiry = 60.seconds,
249257
)
250258

251259
/**

src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -719,7 +719,7 @@ class Peer(
719719
return res.await()
720720
}
721721

722-
suspend fun createInvoice(paymentPreimage: ByteVector32, amount: MilliSatoshi?, description: Either<String, ByteVector32>, expirySeconds: Long? = null): Bolt11Invoice {
722+
suspend fun createInvoice(paymentPreimage: ByteVector32, amount: MilliSatoshi?, description: Either<String, ByteVector32>, expiry: Duration? = null): Bolt11Invoice {
723723
// we add one extra hop which uses a virtual channel with a "peer id", using the highest remote fees and expiry across all
724724
// channels to maximize the likelihood of success on the first payment attempt
725725
val remoteChannelUpdates = _channels.values.mapNotNull { channelState ->
@@ -741,7 +741,7 @@ class Peer(
741741
)
742742
)
743743
)
744-
return incomingPaymentHandler.createInvoice(paymentPreimage, amount, description, extraHops, expirySeconds)
744+
return incomingPaymentHandler.createInvoice(paymentPreimage, amount, description, extraHops, expiry ?: nodeParams.bolt11InvoiceExpiry)
745745
}
746746

747747
// The (node_id, fcm_token) tuple only needs to be registered once.

src/commonMain/kotlin/fr/acinq/lightning/payment/Bolt11Invoice.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,8 @@ data class Bolt11Invoice(
105105
}
106106

107107
companion object {
108-
const val DEFAULT_EXPIRY_SECONDS = 3600
109-
val DEFAULT_MIN_FINAL_EXPIRY_DELTA = CltvExpiryDelta(18)
108+
const val DEFAULT_EXPIRY_SECONDS = 3600 // default value from Bolt 11
109+
val DEFAULT_MIN_FINAL_EXPIRY_DELTA = CltvExpiryDelta(18) // default value from Bolt 11
110110

111111
private val prefixes = mapOf(
112112
Chain.Regtest to "lnbcrt",

src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import fr.acinq.lightning.logging.MDCLogger
1717
import fr.acinq.lightning.logging.mdc
1818
import fr.acinq.lightning.utils.*
1919
import fr.acinq.lightning.wire.*
20+
import kotlin.time.Duration
2021

2122
sealed class PaymentPart {
2223
abstract val amount: MilliSatoshi
@@ -79,7 +80,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) {
7980
amount: MilliSatoshi?,
8081
description: Either<String, ByteVector32>,
8182
extraHops: List<List<Bolt11Invoice.TaggedField.ExtraHop>>,
82-
expirySeconds: Long? = null,
83+
expiry: Duration? = null,
8384
timestampSeconds: Long = currentTimestampSeconds()
8485
): Bolt11Invoice {
8586
val paymentHash = Crypto.sha256(paymentPreimage).toByteVector32()
@@ -95,7 +96,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) {
9596
randomBytes32(),
9697
// We always include a payment metadata in our invoices, which lets us test whether senders support it
9798
ByteVector("2a"),
98-
expirySeconds,
99+
expiry?.inWholeSeconds,
99100
extraHops,
100101
timestampSeconds
101102
)
@@ -159,7 +160,14 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) {
159160
return process(Either.Left(htlc), remoteFeatures, currentBlockHeight, currentFeerate, remoteFundingRates, currentFeeCredit)
160161
}
161162

162-
private suspend fun process(htlc: Either<WillAddHtlc, UpdateAddHtlc>, remoteFeatures: Features, currentBlockHeight: Int, currentFeerate: FeeratePerKw, remoteFundingRates: LiquidityAds.WillFundRates?, currentFeeCredit: MilliSatoshi): ProcessAddResult {
163+
private suspend fun process(
164+
htlc: Either<WillAddHtlc, UpdateAddHtlc>,
165+
remoteFeatures: Features,
166+
currentBlockHeight: Int,
167+
currentFeerate: FeeratePerKw,
168+
remoteFundingRates: LiquidityAds.WillFundRates?,
169+
currentFeeCredit: MilliSatoshi
170+
): ProcessAddResult {
163171
// There are several checks we could perform *before* decrypting the onion.
164172
// But we need to carefully handle which error message is returned to prevent information leakage, so we always peel the onion first.
165173
return when (val res = toPaymentPart(privateKey, htlc)) {
@@ -169,7 +177,14 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) {
169177
}
170178

171179
/** Main payment processing, that handles payment parts. */
172-
private suspend fun processPaymentPart(paymentPart: PaymentPart, remoteFeatures: Features, currentBlockHeight: Int, currentFeerate: FeeratePerKw, remoteFundingRates: LiquidityAds.WillFundRates?, currentFeeCredit: MilliSatoshi): ProcessAddResult {
180+
private suspend fun processPaymentPart(
181+
paymentPart: PaymentPart,
182+
remoteFeatures: Features,
183+
currentBlockHeight: Int,
184+
currentFeerate: FeeratePerKw,
185+
remoteFundingRates: LiquidityAds.WillFundRates?,
186+
currentFeeCredit: MilliSatoshi
187+
): ProcessAddResult {
173188
val logger = MDCLogger(logger.logger, staticMdc = paymentPart.mdc() + ("feeCredit" to currentFeeCredit))
174189
when (paymentPart) {
175190
is HtlcPart -> logger.info { "processing htlc part expiry=${paymentPart.htlc.cltvExpiry}" }
@@ -475,8 +490,8 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) {
475490
logger.warning { "invalid amount (overpayment): ${paymentPart.totalAmount}, expected: ${incomingPayment.origin.paymentRequest.amount}" }
476491
Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight))
477492
}
478-
paymentPart is HtlcPart && paymentPart.htlc.cltvExpiry < minFinalCltvExpiry(incomingPayment.origin.paymentRequest, currentBlockHeight) -> {
479-
logger.warning { "payment with expiry too small: ${paymentPart.htlc.cltvExpiry}, min is ${minFinalCltvExpiry(incomingPayment.origin.paymentRequest, currentBlockHeight)}" }
493+
paymentPart is HtlcPart && paymentPart.htlc.cltvExpiry < minFinalCltvExpiry(nodeParams, paymentPart, incomingPayment.origin, currentBlockHeight) -> {
494+
logger.warning { "payment with expiry too small: ${paymentPart.htlc.cltvExpiry}, min is ${minFinalCltvExpiry(nodeParams, paymentPart, incomingPayment.origin, currentBlockHeight)}" }
480495
Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight))
481496
}
482497
else -> Either.Right(incomingPayment)
@@ -504,11 +519,11 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) {
504519
logger.warning { "invalid amount (underpayment): ${paymentPart.totalAmount}, expected: ${metadata.amount}" }
505520
Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight))
506521
}
507-
paymentPart is HtlcPart && paymentPart.htlc.cltvExpiry < nodeParams.minFinalCltvExpiryDelta.toCltvExpiry(currentBlockHeight.toLong()) -> {
508-
logger.warning { "payment with expiry too small: ${paymentPart.htlc.cltvExpiry}, min is ${nodeParams.minFinalCltvExpiryDelta.toCltvExpiry(currentBlockHeight.toLong())}" }
522+
paymentPart is HtlcPart && paymentPart.htlc.cltvExpiry < minFinalCltvExpiry(nodeParams, paymentPart, incomingPayment.origin, currentBlockHeight) -> {
523+
logger.warning { "payment with expiry too small: ${paymentPart.htlc.cltvExpiry}, min is ${minFinalCltvExpiry(nodeParams, paymentPart, incomingPayment.origin, currentBlockHeight)}" }
509524
Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight))
510525
}
511-
metadata.createdAtMillis + nodeParams.bolt12invoiceExpiry.inWholeMilliseconds < currentTimestampMillis() && incomingPayment.received == null -> {
526+
metadata.createdAtMillis + nodeParams.bolt12InvoiceExpiry.inWholeMilliseconds < currentTimestampMillis() && incomingPayment.received == null -> {
512527
logger.warning { "the invoice is expired" }
513528
Either.Left(rejectPaymentPart(privateKey, paymentPart, incomingPayment, currentBlockHeight))
514529
}
@@ -612,9 +627,23 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) {
612627
return SendOnTheFlyFundingMessage(msg)
613628
}
614629

615-
private fun minFinalCltvExpiry(paymentRequest: Bolt11Invoice, currentBlockHeight: Int): CltvExpiry {
616-
val minFinalCltvExpiryDelta = paymentRequest.minFinalExpiryDelta ?: Bolt11Invoice.DEFAULT_MIN_FINAL_EXPIRY_DELTA
617-
return minFinalCltvExpiryDelta.toCltvExpiry(currentBlockHeight.toLong())
630+
private fun minFinalCltvExpiry(nodeParams: NodeParams, paymentPart: PaymentPart, origin: IncomingPayment.Origin, currentBlockHeight: Int): CltvExpiry {
631+
val minFinalExpiryDelta = when (origin) {
632+
is IncomingPayment.Origin.Invoice -> origin.paymentRequest.minFinalExpiryDelta ?: Bolt11Invoice.DEFAULT_MIN_FINAL_EXPIRY_DELTA
633+
else -> nodeParams.minFinalCltvExpiryDelta
634+
}
635+
return when {
636+
paymentPart is HtlcPart && paymentPart.htlc.usesOnTheFlyFunding -> {
637+
// This HTLC is using on-the-fly funding, so it may have been forwarded with a delay
638+
// because an on-chain transaction was required.
639+
// If the expiry is now below our min_final_expiry_delta, we sill want to accept this
640+
// HTLC if we can do so safely, because we need to pay funding fees by fulfilling it.
641+
// As long as we have time to publish a force-close, it is safe to accept the HTLC.
642+
val overrideFinalExpiryDelta = minFinalExpiryDelta.min(nodeParams.fulfillSafetyBeforeTimeoutBlocks * 2)
643+
overrideFinalExpiryDelta.toCltvExpiry(currentBlockHeight.toLong())
644+
}
645+
else -> minFinalExpiryDelta.toCltvExpiry(currentBlockHeight.toLong())
646+
}
618647
}
619648
}
620649

0 commit comments

Comments
 (0)