Skip to content

Commit

Permalink
Implement on-the-fly funding based on splicing and liquidity ads (#2861)
Browse files Browse the repository at this point in the history
* Add `on_the_fly_funding` feature bit and messages

Add the (disabled by default) `on_the_fly_funding` feature bit and
codecs for the corresponding messages:

- `will_add_htlc`
- `will_fail_htlc`
- `will_fail_malformed_htlc`
- `cancel_on_the_fly_funding`

We also add a TLV to `update_add_htlc` to notify the recipient that we
relayed less data than what the onion encodes, in exchange for the fees
of the specified funding transaction.

* Add `non_initiator_pays_commit_fees` channel flag

We add a non-standard channel flag to `open_channel2` to allow wallets
to ask their peer to pay the commit tx fees, even when they're not the
channel opener. This is necessary for on-the-fly funding, until we can
move to 0-fee commit txs which will make it obsolete.

* Allow underpaying feerate when using future HTLCs

When an interactive-tx session is created for a liquidity purchase that
uses future HTLCs to pay fees, the initiator may not have enough funds
to honor the target feerate. We allow the transaction anyway, because
we want to get paid for the liquidity we're providing. If the feerate
is too low and the transaction doesn't confirm, we can double-spend it
if we need that liquidity elsewhere.

* Add `funding_fee` field to `CMD_ADD_HTLC`

This commit adds the funding fee field to HTLCs, but never sets it.
We update a lot of test files, but there is no functional change.

* Implement on-the-fly funding

Implement the on-the-fly funding protocol: when a payment cannot be
relayed because of a liquidity issue, we notify the `Peer` actor that
we'd like to trigger on-the-fly funding if available. If available, we
we send a funding proposal to our peer and keep track of its status.

Once a matching funding transaction is signed, we persist this funding
attempt and wait for the additional liquidity to be available (once the
channel is ready or the splice locked). We will then frequently try to
relay the payment to get paid our liquidity fees. If the payment keeps
getting rejected, or we cannot connect to our peer, we abandon the
payment when it reaches its CLTV expiry, which ensures that the upstream
channels are not at risk.

When using on-the-fly funding, we use a single channel with our peer.
If they try to open another channel while one is available, we reject
their request and expect a splice instead.
  • Loading branch information
t-bast authored Sep 25, 2024
1 parent db8290f commit de42c8a
Show file tree
Hide file tree
Showing 98 changed files with 3,898 additions and 650 deletions.
8 changes: 8 additions & 0 deletions eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ eclair {
keysend = disabled
trampoline_payment_prototype = disabled
async_payment_prototype = disabled
on_the_fly_funding = disabled
}
// The following section lets you customize features for specific nodes.
// The overrides will be applied on top of the default features settings.
Expand Down Expand Up @@ -340,6 +341,13 @@ eclair {
]
}

// On-the-fly funding leverages liquidity ads to fund channels with wallet peers based on their payment patterns.
on-the-fly-funding {
// If our peer doesn't respond to our funding proposal, we must fail the corresponding upstream HTLCs.
// Since MPP may be used, we should use a timeout greater than the MPP timeout.
proposal-timeout = 90 seconds
}

peer-connection {
auth-timeout = 15 seconds // will disconnect if connection authentication doesn't happen within that timeframe
init-timeout = 15 seconds // will disconnect if initialization doesn't happen within that timeframe
Expand Down
13 changes: 12 additions & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/Features.scala
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,15 @@ object Features {
val mandatory = 154
}

/**
* Activate this feature to provide on-the-fly funding to remote nodes, as specified in bLIP 36: https://github.com/lightning/blips/blob/master/blip-0036.md.
* TODO: add NodeFeature once bLIP is merged.
*/
case object OnTheFlyFunding extends Feature with InitFeature {
val rfcName = "on_the_fly_funding"
val mandatory = 560
}

val knownFeatures: Set[Feature] = Set(
DataLossProtect,
InitialRoutingSync,
Expand All @@ -349,6 +358,7 @@ object Features {
TrampolinePaymentPrototype,
AsyncPaymentPrototype,
SplicePrototype,
OnTheFlyFunding
)

// Features may depend on other features, as specified in Bolt 9.
Expand All @@ -361,7 +371,8 @@ object Features {
RouteBlinding -> (VariableLengthOnion :: Nil),
TrampolinePaymentPrototype -> (PaymentSecret :: Nil),
KeySend -> (VariableLengthOnion :: Nil),
AsyncPaymentPrototype -> (TrampolinePaymentPrototype :: Nil)
AsyncPaymentPrototype -> (TrampolinePaymentPrototype :: Nil),
OnTheFlyFunding -> (SplicePrototype :: Nil)
)

case class FeatureException(message: String) extends IllegalArgumentException(message)
Expand Down
14 changes: 11 additions & 3 deletions eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import fr.acinq.eclair.db._
import fr.acinq.eclair.io.MessageRelay.{RelayAll, RelayChannelsOnly, RelayPolicy}
import fr.acinq.eclair.io.{PeerConnection, PeerReadyNotifier}
import fr.acinq.eclair.message.OnionMessages.OnionMessageConfig
import fr.acinq.eclair.payment.relay.OnTheFlyFunding
import fr.acinq.eclair.payment.relay.Relayer.{AsyncPaymentsParams, RelayFees, RelayParams}
import fr.acinq.eclair.router.Announcements.AddressException
import fr.acinq.eclair.router.Graph.{HeuristicsConstants, WeightRatios}
Expand Down Expand Up @@ -90,7 +91,8 @@ case class NodeParams(nodeKeyManager: NodeKeyManager,
purgeInvoicesInterval: Option[FiniteDuration],
revokedHtlcInfoCleanerConfig: RevokedHtlcInfoCleaner.Config,
willFundRates_opt: Option[LiquidityAds.WillFundRates],
peerWakeUpConfig: PeerReadyNotifier.WakeUpConfig) {
peerWakeUpConfig: PeerReadyNotifier.WakeUpConfig,
onTheFlyFundingConfig: OnTheFlyFunding.Config) {
val privateKey: Crypto.PrivateKey = nodeKeyManager.nodeKey.privateKey

val nodeId: PublicKey = nodeKeyManager.nodeId
Expand Down Expand Up @@ -504,7 +506,10 @@ object NodeParams extends Logging {

val willFundRates_opt = {
val supportedPaymentTypes = Map(
LiquidityAds.PaymentType.FromChannelBalance.rfcName -> LiquidityAds.PaymentType.FromChannelBalance
LiquidityAds.PaymentType.FromChannelBalance.rfcName -> LiquidityAds.PaymentType.FromChannelBalance,
LiquidityAds.PaymentType.FromChannelBalanceForFutureHtlc.rfcName -> LiquidityAds.PaymentType.FromChannelBalanceForFutureHtlc,
LiquidityAds.PaymentType.FromFutureHtlc.rfcName -> LiquidityAds.PaymentType.FromFutureHtlc,
LiquidityAds.PaymentType.FromFutureHtlcWithPreimage.rfcName -> LiquidityAds.PaymentType.FromFutureHtlcWithPreimage,
)
val paymentTypes: Set[LiquidityAds.PaymentType] = config.getStringList("liquidity-ads.payment-types").asScala.map(s => {
supportedPaymentTypes.get(s) match {
Expand Down Expand Up @@ -668,7 +673,10 @@ object NodeParams extends Logging {
willFundRates_opt = willFundRates_opt,
peerWakeUpConfig = PeerReadyNotifier.WakeUpConfig(
enabled = config.getBoolean("peer-wake-up.enabled"),
timeout = FiniteDuration(config.getDuration("peer-wake-up.timeout").getSeconds, TimeUnit.SECONDS)
timeout = FiniteDuration(config.getDuration("peer-wake-up.timeout").getSeconds, TimeUnit.SECONDS),
),
onTheFlyFundingConfig = OnTheFlyFunding.Config(
proposalTimeout = FiniteDuration(config.getDuration("on-the-fly-funding.proposal-timeout").getSeconds, TimeUnit.SECONDS),
),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ case class INPUT_INIT_CHANNEL_NON_INITIATOR(temporaryChannelId: ByteVector32,
fundingContribution_opt: Option[LiquidityAds.AddFunding],
dualFunded: Boolean,
pushAmount_opt: Option[MilliSatoshi],
requireConfirmedInputs: Boolean,
localParams: LocalParams,
remote: ActorRef,
remoteInit: Init,
Expand Down Expand Up @@ -146,7 +147,7 @@ object Upstream {
val expiryIn: CltvExpiry = add.cltvExpiry
}
/** Our node is forwarding a payment based on a set of HTLCs from potentially multiple upstream channels. */
case class Trampoline(received: Seq[Channel]) extends Hot {
case class Trampoline(received: List[Channel]) extends Hot {
override val amountIn: MilliSatoshi = received.map(_.add.amountMsat).sum
// We must use the lowest expiry of the incoming HTLC set.
val expiryIn: CltvExpiry = received.map(_.add.cltvExpiry).min
Expand All @@ -165,6 +166,10 @@ object Upstream {

/** Our node is forwarding a single incoming HTLC. */
case class Channel(originChannelId: ByteVector32, originHtlcId: Long, amountIn: MilliSatoshi) extends Cold
object Channel {
def apply(add: UpdateAddHtlc): Channel = Channel(add.channelId, add.id, add.amountMsat)
}

/** Our node is forwarding a payment based on a set of HTLCs from potentially multiple upstream channels. */
case class Trampoline(originHtlcs: List[Channel]) extends Cold { override val amountIn: MilliSatoshi = originHtlcs.map(_.amountIn).sum }
}
Expand Down Expand Up @@ -197,7 +202,17 @@ sealed trait HasOptionalReplyToCommand extends Command { def replyTo_opt: Option
sealed trait ForbiddenCommandDuringSplice extends Command
sealed trait ForbiddenCommandDuringQuiescence extends Command

final case class CMD_ADD_HTLC(replyTo: ActorRef, amount: MilliSatoshi, paymentHash: ByteVector32, cltvExpiry: CltvExpiry, onion: OnionRoutingPacket, nextBlindingKey_opt: Option[PublicKey], confidence: Double, origin: Origin.Hot, commit: Boolean = false) extends HasReplyToCommand with ForbiddenCommandDuringSplice with ForbiddenCommandDuringQuiescence
final case class CMD_ADD_HTLC(replyTo: ActorRef,
amount: MilliSatoshi,
paymentHash: ByteVector32,
cltvExpiry: CltvExpiry,
onion: OnionRoutingPacket,
nextBlindingKey_opt: Option[PublicKey],
confidence: Double,
fundingFee_opt: Option[LiquidityAds.FundingFee],
origin: Origin.Hot,
commit: Boolean = false) extends HasReplyToCommand with ForbiddenCommandDuringSplice with ForbiddenCommandDuringQuiescence

sealed trait HtlcSettlementCommand extends HasOptionalReplyToCommand with ForbiddenCommandDuringSplice with ForbiddenCommandDuringQuiescence { def id: Long }
final case class CMD_FULFILL_HTLC(id: Long, r: ByteVector32, commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HtlcSettlementCommand
final case class CMD_FAIL_HTLC(id: Long, reason: Either[ByteVector, FailureMessage], delay_opt: Option[FiniteDuration] = None, commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HtlcSettlementCommand
Expand Down Expand Up @@ -666,8 +681,16 @@ case class RemoteParams(nodeId: PublicKey,
initFeatures: Features[InitFeature],
upfrontShutdownScript_opt: Option[ByteVector])

case class ChannelFlags(announceChannel: Boolean) {
override def toString: String = s"ChannelFlags(announceChannel=$announceChannel)"
/**
* The [[nonInitiatorPaysCommitFees]] parameter is set to true when the sender wants the receiver to pay the commitment transaction fees.
* This is not part of the BOLTs and won't be needed anymore once commitment transactions don't pay any on-chain fees.
*/
case class ChannelFlags(nonInitiatorPaysCommitFees: Boolean, announceChannel: Boolean) {
override def toString: String = s"ChannelFlags(announceChannel=$announceChannel, nonInitiatorPaysCommitFees=$nonInitiatorPaysCommitFees)"
}

object ChannelFlags {
def apply(announceChannel: Boolean): ChannelFlags = ChannelFlags(nonInitiatorPaysCommitFees = false, announceChannel = announceChannel)
}

/** Information about what triggered the opening of the channel */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ case class ChannelAborted(channel: ActorRef, remoteNodeId: PublicKey, channelId:
/** This event will be sent once a channel has been successfully opened and is ready to process payments. */
case class ChannelOpened(channel: ActorRef, remoteNodeId: PublicKey, channelId: ByteVector32) extends ChannelEvent

/** This event is sent once channel_ready or splice_locked have been exchanged. */
case class ChannelReadyForPayments(channel: ActorRef, remoteNodeId: PublicKey, channelId: ByteVector32, fundingTxIndex: Long) extends ChannelEvent

case class LocalChannelUpdate(channel: ActorRef, channelId: ByteVector32, shortIds: ShortIds, remoteNodeId: PublicKey, channelAnnouncement_opt: Option[ChannelAnnouncement], channelUpdate: ChannelUpdate, commitments: Commitments) extends ChannelEvent {
/**
* We always include the local alias because we must always be able to route based on it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -854,7 +854,7 @@ case class Commitments(params: ChannelParams,
return Left(HtlcValueTooSmall(params.channelId, minimum = htlcMinimum, actual = cmd.amount))
}

val add = UpdateAddHtlc(channelId, changes.localNextHtlcId, cmd.amount, cmd.paymentHash, cmd.cltvExpiry, cmd.onion, cmd.nextBlindingKey_opt, cmd.confidence)
val add = UpdateAddHtlc(channelId, changes.localNextHtlcId, cmd.amount, cmd.paymentHash, cmd.cltvExpiry, cmd.onion, cmd.nextBlindingKey_opt, cmd.confidence, cmd.fundingFee_opt)
// we increment the local htlc index and add an entry to the origins map
val changes1 = changes.addLocalProposal(add).copy(localNextHtlcId = changes.localNextHtlcId + 1)
val originChannels1 = originChannels + (add.id -> cmd.origin)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import fr.acinq.eclair.crypto.keymanager.ChannelKeyManager
import fr.acinq.eclair.db.DbEventHandler.ChannelEvent.EventType
import fr.acinq.eclair.db.PendingCommandsDb
import fr.acinq.eclair.io.Peer
import fr.acinq.eclair.io.Peer.LiquidityPurchaseSigned
import fr.acinq.eclair.payment.relay.Relayer
import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentSettlingOnChain}
import fr.acinq.eclair.router.Announcements
Expand Down Expand Up @@ -1095,10 +1096,13 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
log.info("ignoring outgoing interactive-tx message {} from previous session", msg.getClass.getSimpleName)
stay()
}
case InteractiveTxBuilder.Succeeded(signingSession, commitSig) =>
case InteractiveTxBuilder.Succeeded(signingSession, commitSig, liquidityPurchase_opt) =>
log.info(s"splice tx created with fundingTxIndex=${signingSession.fundingTxIndex} fundingTxId=${signingSession.fundingTx.txId}")
cmd_opt.foreach(cmd => cmd.replyTo ! RES_SPLICE(fundingTxIndex = signingSession.fundingTxIndex, signingSession.fundingTx.txId, signingSession.fundingParams.fundingAmount, signingSession.localCommit.fold(_.spec, _.spec).toLocal))
remoteCommitSig_opt.foreach(self ! _)
liquidityPurchase_opt.collect {
case purchase if !signingSession.fundingParams.isInitiator => peer ! LiquidityPurchaseSigned(d.channelId, signingSession.fundingTx.txId, signingSession.fundingTxIndex, d.commitments.params.remoteParams.htlcMinimum, purchase)
}
val d1 = d.copy(spliceStatus = SpliceStatus.SpliceWaitingForSigs(signingSession))
stay() using d1 storing() sending commitSig
case f: InteractiveTxBuilder.Failed =>
Expand Down Expand Up @@ -2139,6 +2143,12 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
}
}

// We tell the peer that the channel is ready to process payments that may be queued.
if (!shutdownInProgress) {
val fundingTxIndex = commitments1.active.map(_.fundingTxIndex).min
peer ! ChannelReadyForPayments(self, remoteNodeId, d.channelId, fundingTxIndex)
}

goto(NORMAL) using d.copy(commitments = commitments1, spliceStatus = spliceStatus1) sending sendQueue
}

Expand Down Expand Up @@ -2710,6 +2720,12 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
if (oldCommitments.availableBalanceForSend != newCommitments.availableBalanceForSend || oldCommitments.availableBalanceForReceive != newCommitments.availableBalanceForReceive) {
context.system.eventStream.publish(AvailableBalanceChanged(self, newCommitments.channelId, shortIds, newCommitments))
}
if (oldCommitments.active.size != newCommitments.active.size) {
// Some commitments have been deactivated, which means our available balance changed, which may allow forwarding
// payments that couldn't be forwarded before.
val fundingTxIndex = newCommitments.active.map(_.fundingTxIndex).min
peer ! ChannelReadyForPayments(self, remoteNodeId, newCommitments.channelId, fundingTxIndex)
}
}

private def maybeUpdateMaxHtlcAmount(currentMaxHtlcAmount: MilliSatoshi, newCommitments: Commitments): Unit = {
Expand Down
Loading

0 comments on commit de42c8a

Please sign in to comment.