Skip to content

Commit

Permalink
fee credit + auto liquidity basic prototype
Browse files Browse the repository at this point in the history
This is a basic prototype for:
- fee-credit: instead of rejecting a pay-to-open request that is too expensive (in
absolute or relative terms), add the option to put the amount aside to pay for future
mining/service fees.
- auto-liquidity: inform the peer that we would like additional
  liquidity during the next splice operation.

We also take the opportunity to do some clean-up in the
`PayToOpenRequest` class by ignoring unused fields.
  • Loading branch information
pm47 committed Jul 16, 2024
1 parent 3fc31a9 commit b4e7997
Show file tree
Hide file tree
Showing 9 changed files with 189 additions and 72 deletions.
21 changes: 14 additions & 7 deletions src/commonMain/kotlin/fr/acinq/lightning/NodeEvents.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,22 @@ sealed interface LiquidityEvents : NodeEvents {
val source: Source

enum class Source { OnChainWallet, OffChainPayment }
data class Rejected(override val amount: MilliSatoshi, override val fee: MilliSatoshi, override val source: Source, val reason: Reason) : LiquidityEvents {
sealed class Reason {
data object PolicySetToDisabled : Reason()
sealed class TooExpensive : Reason() {
data class OverAbsoluteFee(val maxAbsoluteFee: Satoshi) : TooExpensive()
data class OverRelativeFee(val maxRelativeFeeBasisPoints: Int) : TooExpensive()

sealed interface Decision : LiquidityEvents {
data class Rejected(override val amount: MilliSatoshi, override val fee: MilliSatoshi, override val source: Source, val reason: Reason) : Decision {
sealed class Reason {
data object PolicySetToDisabled : Reason()
sealed class TooExpensive : Reason() {
data class OverAbsoluteFee(val maxAbsoluteFee: Satoshi) : TooExpensive()
data class OverRelativeFee(val maxRelativeFeeBasisPoints: Int) : TooExpensive()
}
data class OverMaxCredit(val maxAllowedCredit: Satoshi) : TooExpensive()

data object ChannelInitializing : Reason()
}
data object ChannelInitializing : Reason()
}
data class AddedToFeeCredit(override val amount: MilliSatoshi, override val fee: MilliSatoshi, override val source: Source) : Decision
data class Accepted(override val amount: MilliSatoshi, override val fee: MilliSatoshi, override val source: Source) : Decision
}

data class ApprovalRequested(override val amount: MilliSatoshi, override val fee: MilliSatoshi, override val source: Source, val replyTo: CompletableDeferred<Boolean>) : LiquidityEvents
Expand Down
17 changes: 12 additions & 5 deletions src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,7 @@ import fr.acinq.lightning.utils.toMilliSatoshi
import fr.acinq.lightning.wire.OfferTypes
import io.ktor.utils.io.charsets.*
import io.ktor.utils.io.core.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.*
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
Expand Down Expand Up @@ -163,6 +160,9 @@ data class NodeParams(
internal val _nodeEvents = MutableSharedFlow<NodeEvents>(replay = 10)
val nodeEvents: SharedFlow<NodeEvents> get() = _nodeEvents.asSharedFlow()

internal val _feeCredit = MutableStateFlow<Satoshi>(0.sat)
val feeCredit: StateFlow<Satoshi> get() = _feeCredit.asStateFlow()

init {
require(features.hasFeature(Feature.VariableLengthOnion, FeatureSupport.Mandatory)) { "${Feature.VariableLengthOnion.rfcName} should be mandatory" }
require(features.hasFeature(Feature.PaymentSecret, FeatureSupport.Mandatory)) { "${Feature.PaymentSecret.rfcName} should be mandatory" }
Expand Down Expand Up @@ -229,7 +229,14 @@ data class NodeParams(
maxPaymentAttempts = 5,
zeroConfPeers = emptySet(),
paymentRecipientExpiryParams = RecipientCltvExpiryParams(CltvExpiryDelta(75), CltvExpiryDelta(200)),
liquidityPolicy = MutableStateFlow<LiquidityPolicy>(LiquidityPolicy.Auto(maxAbsoluteFee = 2_000.sat, maxRelativeFeeBasisPoints = 3_000 /* 3000 = 30 % */, skipAbsoluteFeeCheck = false)),
liquidityPolicy = MutableStateFlow<LiquidityPolicy>(
LiquidityPolicy.Auto(
maxAbsoluteFee = 2_000.sat,
maxRelativeFeeBasisPoints = 3_000 /* 3000 = 30 % */,
skipAbsoluteFeeCheck = false,
maxAllowedCredit = 0.sat
)
),
minFinalCltvExpiryDelta = Bolt11Invoice.DEFAULT_MIN_FINAL_EXPIRY_DELTA,
maxFinalCltvExpiryDelta = CltvExpiryDelta(360),
bolt12invoiceExpiry = 60.seconds,
Expand Down
4 changes: 4 additions & 0 deletions src/commonMain/kotlin/fr/acinq/lightning/db/PaymentsDb.kt
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,10 @@ data class IncomingPayment(val preimage: ByteVector32, val origin: Origin, val r
override val fees: MilliSatoshi = 0.msat // with Lightning, the fee is paid by the sender
}

data class FeeCreditPayment(override val amount: MilliSatoshi) : ReceivedWith() {
override val fees: MilliSatoshi get() = 0.msat // there are no fees when payment is added to the fee credit
}

sealed class OnChainIncomingPayment : ReceivedWith() {
abstract val serviceFee: MilliSatoshi
abstract val miningFee: Satoshi
Expand Down
41 changes: 29 additions & 12 deletions src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,11 @@ class Peer(
return replyTo.await()
}

fun setAutoLiquidityParams(amount: Satoshi) {
logger.info { "setting auto-liquidity=$amount" }
peerConnection?.send(AutoLiquidityParams(amount))
}

sealed class SelectChannelResult {
/** We have a channel that is available for payments and splicing. */
data class Available(val channel: Normal) : SelectChannelResult()
Expand Down Expand Up @@ -1032,12 +1037,16 @@ class Peer(
is Origin.PleaseOpenChannelOrigin -> when (val request = channelRequests[origin.requestId]) {
is RequestChannelOpen -> {
val totalFee = origin.serviceFee + origin.miningFee.toMilliSatoshi() - msg.pushAmount
nodeParams.liquidityPolicy.value.maybeReject(request.walletInputs.balance.toMilliSatoshi(), totalFee, LiquidityEvents.Source.OnChainWallet, logger)?.let { rejected ->
logger.info { "rejecting open_channel2: reason=${rejected.reason}" }
nodeParams._nodeEvents.emit(rejected)
swapInCommands.send(SwapInCommand.UnlockWalletInputs(request.walletInputs.map { it.outPoint }.toSet()))
peerConnection?.send(Error(msg.temporaryChannelId, "cancelling open due to local liquidity policy"))
return
val decision = nodeParams.liquidityPolicy.value.maybeReject(request.walletInputs.balance.toMilliSatoshi(), totalFee, LiquidityEvents.Source.OnChainWallet, logger, nodeParams.feeCredit.value)
when (decision) {
is LiquidityEvents.Decision.Rejected -> {
logger.info { "rejecting open_channel2: reason=${decision.reason}" }
nodeParams._nodeEvents.emit(decision)
swapInCommands.send(SwapInCommand.UnlockWalletInputs(request.walletInputs.map { it.outPoint }.toSet()))
peerConnection?.send(Error(msg.temporaryChannelId, "cancelling open due to local liquidity policy"))
return
}
else -> {}
}
val fundingFee = Transactions.weight2fee(msg.fundingFeerate, FundingContributions.weight(request.walletInputs))
// We have to pay the fees for our inputs, so we deduce them from our funding amount.
Expand Down Expand Up @@ -1174,7 +1183,7 @@ class Peer(
// the payment in db when we will process the corresponding splice and see the pay-to-open origin. This
// can take a long time depending on the confirmation speed. It is better and simpler to reject the incoming
// payment rather that having the user wonder where their money went.
val rejected = LiquidityEvents.Rejected(msg.amountMsat, msg.payToOpenFeeSatoshis.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Rejected.Reason.ChannelInitializing)
val rejected = LiquidityEvents.Decision.Rejected(msg.amountMsat, msg.payToOpenFeeSatoshis.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment, LiquidityEvents.Decision.Rejected.Reason.ChannelInitializing)
logger.info { "rejecting pay-to-open: reason=${rejected.reason}" }
nodeParams._nodeEvents.emit(rejected)
val action = IncomingPaymentHandler.actionForPayToOpenFailure(nodeParams.nodePrivateKey, TemporaryNodeFailure, msg)
Expand Down Expand Up @@ -1214,6 +1223,10 @@ class Peer(
logger.info { "bip353 dns address assigned: ${msg.address}" }
_eventsFlow.emit(AddressAssigned(msg.address))
}
is CurrentFeeCredit -> {
logger.info { "current fee credit: ${msg.amount}" }
nodeParams._feeCredit.emit(msg.amount)
}
}
}
is WatchReceived -> {
Expand All @@ -1236,11 +1249,15 @@ class Peer(
val (feerate, fee) = client.computeSpliceCpfpFeerate(available.channel.commitments, targetFeerate, spliceWeight = weight, logger)

logger.info { "requesting splice-in using balance=${cmd.walletInputs.balance} feerate=$feerate fee=$fee" }
nodeParams.liquidityPolicy.value.maybeReject(cmd.walletInputs.balance.toMilliSatoshi(), fee.toMilliSatoshi(), LiquidityEvents.Source.OnChainWallet, logger)?.let { rejected ->
logger.info { "rejecting splice: reason=${rejected.reason}" }
nodeParams._nodeEvents.emit(rejected)
swapInCommands.send(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet()))
return
val decision = nodeParams.liquidityPolicy.value.maybeReject(cmd.walletInputs.balance.toMilliSatoshi(), fee.toMilliSatoshi(), LiquidityEvents.Source.OnChainWallet, logger, nodeParams.feeCredit.value)
nodeParams._nodeEvents.emit(decision)
when (decision) {
is LiquidityEvents.Decision.Rejected -> {
logger.info { "rejecting splice: reason=${decision.reason}" }
swapInCommands.send(SwapInCommand.UnlockWalletInputs(cmd.walletInputs.map { it.outPoint }.toSet()))
return
}
else -> {}
}

val spliceCommand = ChannelCommand.Commitment.Splice.Request(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,23 +255,35 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment
return ProcessAddResult.Pending(incomingPayment, payment)
}
else -> {
if (payment.parts.filterIsInstance<PayToOpenPart>().isNotEmpty()) {
val liquidityDecision = if (payment.parts.filterIsInstance<PayToOpenPart>().isNotEmpty()) {
// We consider the total amount received (not only the pay-to-open parts) to evaluate whether or not to accept the payment
val payToOpenFee = payment.parts.filterIsInstance<PayToOpenPart>().map { it.payToOpenRequest.payToOpenFeeSatoshis }.sum()
nodeParams.liquidityPolicy.value.maybeReject(payment.amountReceived, payToOpenFee.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment, logger)?.let { rejected ->
logger.info { "rejecting pay-to-open: reason=${rejected.reason}" }
nodeParams._nodeEvents.emit(rejected)
val actions = payment.parts.map { part ->
val failureMsg = TemporaryNodeFailure
when (part) {
is HtlcPart -> actionForFailureMessage(failureMsg, part.htlc)
is PayToOpenPart -> actionForPayToOpenFailure(privateKey, failureMsg, part.payToOpenRequest) // NB: this will fail all parts, we could only return one
val decision = nodeParams.liquidityPolicy.value.maybeReject(payment.amountReceived, payToOpenFee.toMilliSatoshi(), LiquidityEvents.Source.OffChainPayment, logger, nodeParams.feeCredit.value)
logger.info { "pay-to-open decision: $decision" }
nodeParams._nodeEvents.emit(decision)
when (decision) {
is LiquidityEvents.Decision.Rejected -> {
logger.info { "rejecting pay-to-open: reason=${decision.reason}" }
val actions = payment.parts.map { part ->
val failureMsg = TemporaryNodeFailure
when (part) {
is HtlcPart -> actionForFailureMessage(failureMsg, part.htlc)
is PayToOpenPart -> actionForPayToOpenFailure(privateKey, failureMsg, part.payToOpenRequest) // NB: this will fail all parts, we could only return one
}
}
pending.remove(paymentPart.paymentHash)
return ProcessAddResult.Rejected(actions, incomingPayment)
}
is LiquidityEvents.Decision.AddedToFeeCredit -> {
logger.info { "added pay-to-open to fee credit" }
decision
}
is LiquidityEvents.Decision.Accepted -> {
logger.info { "accepted pay-to-open" }
decision
}
pending.remove(paymentPart.paymentHash)
return ProcessAddResult.Rejected(actions, incomingPayment)
}
}
} else null

when (val finalPayload = paymentPart.finalPayload) {
is PaymentOnion.FinalPayload.Standard -> when (finalPayload.paymentMetadata) {
Expand All @@ -285,12 +297,21 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment
// We only fill the DB with htlc parts, because we cannot be sure yet that our peer will honor the pay-to-open part(s).
// When the payment contains pay-to-open parts, it will be considered received, but the sum of all parts will be smaller
// than the expected amount. The pay-to-open part(s) will be added once we received the corresponding new channel or a splice-in.
val receivedWith = htlcParts.map { part ->
IncomingPayment.ReceivedWith.LightningPayment(
amount = part.amount,
htlcId = part.htlc.id,
channelId = part.htlc.channelId
)
val receivedWith = buildList {
addAll(htlcParts.map { part ->
IncomingPayment.ReceivedWith.LightningPayment(
amount = part.amount,
htlcId = part.htlc.id,
channelId = part.htlc.channelId
)
})
if (liquidityDecision is LiquidityEvents.Decision.AddedToFeeCredit) {
addAll(payToOpenParts.map { part ->
IncomingPayment.ReceivedWith.FeeCreditPayment(
amount = part.amount
)
})
}
}
val actions = buildList {
htlcParts.forEach { part ->
Expand All @@ -299,7 +320,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment
}
// We avoid sending duplicate pay-to-open responses, since the preimage is the same for every part.
if (payToOpenParts.isNotEmpty()) {
val response = PayToOpenResponse(nodeParams.chainHash, incomingPayment.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage))
val response = PayToOpenResponse(nodeParams.chainHash, incomingPayment.paymentHash, PayToOpenResponse.Result.Success(incomingPayment.preimage, addToFeeCredit = liquidityDecision is LiquidityEvents.Decision.AddedToFeeCredit))
add(PayToOpenResponseCommand(response))
}
}
Expand Down
Loading

0 comments on commit b4e7997

Please sign in to comment.