Skip to content

Commit

Permalink
Add funding_fee_credit feature
Browse files Browse the repository at this point in the history
We add an optional feature that lets on-the-fly funding clients accept
payments that are too small to pay the fees for an on-the-fly funding.
When that happens, the payment amount is added as "fee credit" without
performing an on-chain operation. Once enough fee credit has been
obtained, we can initiate an on-chain operation to create a channel or
a splice by paying part of the fees from the fee credit.

This feature makes more efficient use of on-chain transactions by
trusting that the seller will honor our fee credit in the future. The
fee credit takes precedence over other ways of paying the fees (from
the channel balance or future HTLCs), which guarantees that the fee
credit eventually converges to 0.
  • Loading branch information
t-bast committed Aug 28, 2024
1 parent 3b06ce1 commit b1e18a8
Show file tree
Hide file tree
Showing 22 changed files with 850 additions and 103 deletions.
14 changes: 12 additions & 2 deletions eclair-core/src/main/scala/fr/acinq/eclair/Features.scala
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,14 @@ object Features {
val mandatory = 560
}

// TODO:
// - add NodeFeature once stable
// - add link to bLIP
case object FundingFeeCredit extends Feature with InitFeature {
val rfcName = "funding_fee_credit"
val mandatory = 562
}

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

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

case class FeatureException(message: String) extends IllegalArgumentException(message)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ object Helpers {

for {
script_opt <- extractShutdownScript(open.temporaryChannelId, localFeatures, remoteFeatures, open.upfrontShutdownScript_opt)
willFund_opt <- LiquidityAds.validateRequest(nodeParams.privateKey, open.temporaryChannelId, fundingScript, open.fundingFeerate, open.requestFunding_opt, addFunding_opt.flatMap(_.rates_opt))
willFund_opt <- LiquidityAds.validateRequest(nodeParams.privateKey, open.temporaryChannelId, fundingScript, open.fundingFeerate, open.requestFunding_opt, addFunding_opt.flatMap(_.rates_opt), open.useFeeCredit_opt)
} yield (channelFeatures, script_opt, willFund_opt)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -952,7 +952,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
val parentCommitment = d.commitments.latest.commitment
val localFundingPubKey = nodeParams.channelKeyManager.fundingPublicKey(d.commitments.params.localParams.fundingKeyPath, parentCommitment.fundingTxIndex + 1).publicKey
val fundingScript = Funding.makeFundingPubKeyScript(localFundingPubKey, msg.fundingPubKey)
LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, msg.requestFunding_opt, nodeParams.willFundRates_opt) match {
LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, msg.requestFunding_opt, nodeParams.willFundRates_opt, msg.useFeeCredit_opt) match {
case Left(t) =>
log.warning("rejecting splice request with invalid liquidity ads: {}", t.getMessage)
stay() using d.copy(spliceStatus = SpliceStatus.SpliceAborted) sending TxAbort(d.channelId, t.getMessage)
Expand All @@ -963,7 +963,8 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
fundingPubKey = localFundingPubKey,
pushAmount = 0.msat,
requireConfirmedInputs = nodeParams.channelConf.requireConfirmedInputsForDualFunding,
willFund_opt = willFund_opt.map(_.willFund)
willFund_opt = willFund_opt.map(_.willFund),
feeCreditUsed_opt = msg.useFeeCredit_opt
)
val fundingParams = InteractiveTxParams(
channelId = d.channelId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers {
Some(ChannelTlv.ChannelTypeTlv(d.init.channelType)),
if (nodeParams.channelConf.requireConfirmedInputsForDualFunding) Some(ChannelTlv.RequireConfirmedInputsTlv()) else None,
willFund_opt.map(l => ChannelTlv.ProvideFundingTlv(l.willFund)),
open.useFeeCredit_opt.map(c => ChannelTlv.FeeCreditUsedTlv(c)),
d.init.pushAmount_opt.map(amount => ChannelTlv.PushAmountTlv(amount)),
).flatten
val accept = AcceptDualFundedChannel(
Expand Down Expand Up @@ -547,7 +548,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers {
stay() using d.copy(rbfStatus = RbfStatus.RbfAborted) sending TxAbort(d.channelId, InvalidRbfAttemptTooSoon(d.channelId, d.latestFundingTx.createdAt, d.latestFundingTx.createdAt + nodeParams.channelConf.remoteRbfLimits.attemptDeltaBlocks).getMessage)
} else {
val fundingScript = d.commitments.latest.commitInput.txOut.publicKeyScript
LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, msg.requestFunding_opt, nodeParams.willFundRates_opt) match {
LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, msg.requestFunding_opt, nodeParams.willFundRates_opt, None) match {
case Left(t) =>
log.warning("rejecting rbf attempt: invalid liquidity ads request ({})", t.getMessage)
stay() using d.copy(rbfStatus = RbfStatus.RbfAborted) sending TxAbort(d.channelId, t.getMessage)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import fr.acinq.eclair.crypto.keymanager.ChannelKeyManager
import fr.acinq.eclair.transactions.Transactions.{CommitTx, HtlcTx, InputInfo, TxOwner}
import fr.acinq.eclair.transactions.{CommitmentSpec, DirectedHtlc, Scripts, Transactions}
import fr.acinq.eclair.wire.protocol._
import fr.acinq.eclair.{Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, UInt64}
import fr.acinq.eclair.{Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, ToMilliSatoshiConversion, UInt64}
import scodec.bits.ByteVector

import scala.concurrent.{ExecutionContext, Future}
Expand Down Expand Up @@ -155,13 +155,18 @@ object InteractiveTxBuilder {
// BOLT 2: the initiator's serial IDs MUST use even values and the non-initiator odd values.
val serialIdParity: Int = if (isInitiator) 0 else 1

def liquidityFees(liquidityPurchase_opt: Option[LiquidityAds.Purchase]): Satoshi = {
def liquidityFees(liquidityPurchase_opt: Option[LiquidityAds.Purchase]): MilliSatoshi = {
liquidityPurchase_opt.map(l => l.paymentDetails match {
// The initiator of the interactive-tx is the liquidity buyer (if liquidity ads is used).
case LiquidityAds.PaymentDetails.FromChannelBalance | _: LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc => if (isInitiator) l.fees.total else -l.fees.total
case LiquidityAds.PaymentDetails.FromChannelBalance | _: LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc =>
val feesOwed = l match {
case l: LiquidityAds.Purchase.Standard => l.fees.total.toMilliSatoshi
case l: LiquidityAds.Purchase.WithFeeCredit => l.fees.total.toMilliSatoshi - l.feeCreditUsed
}
if (isInitiator) feesOwed else -feesOwed
// Fees will be paid later, when relaying HTLCs.
case _: LiquidityAds.PaymentDetails.FromFutureHtlc | _: LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage => 0.sat
}).getOrElse(0 sat)
case _: LiquidityAds.PaymentDetails.FromFutureHtlc | _: LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage => 0 msat
}).getOrElse(0 msat)
}
}

Expand Down Expand Up @@ -734,6 +739,16 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
}

liquidityPurchase_opt match {
case Some(p: LiquidityAds.Purchase.WithFeeCredit) if !fundingParams.isInitiator =>
val currentFeeCredit = nodeParams.db.onTheFlyFunding.getFeeCredit(remoteNodeId)
if (currentFeeCredit < p.feeCreditUsed) {
log.warn("not enough fee credit: our peer may be malicious ({} < {})", currentFeeCredit, p.feeCreditUsed)
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId))
}
case _ => ()
}

previousTransactions.headOption match {
case Some(previousTx) =>
// This is an RBF attempt: even if our peer does not contribute to the feerate increase, we'd like to broadcast
Expand Down
15 changes: 15 additions & 0 deletions eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala
Original file line number Diff line number Diff line change
Expand Up @@ -442,4 +442,19 @@ case class DualOnTheFlyFundingDb(primary: OnTheFlyFundingDb, secondary: OnTheFly
runAsync(secondary.listPendingPayments())
primary.listPendingPayments()
}

override def addFeeCredit(nodeId: PublicKey, amount: MilliSatoshi, receivedAt: TimestampMilli): MilliSatoshi = {
runAsync(secondary.addFeeCredit(nodeId, amount, receivedAt))
primary.addFeeCredit(nodeId, amount, receivedAt)
}

override def getFeeCredit(nodeId: PublicKey): MilliSatoshi = {
runAsync(secondary.getFeeCredit(nodeId))
primary.getFeeCredit(nodeId)
}

override def removeFeeCredit(nodeId: PublicKey, amountUsed: MilliSatoshi): MilliSatoshi = {
runAsync(secondary.removeFeeCredit(nodeId, amountUsed))
primary.removeFeeCredit(nodeId, amountUsed)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package fr.acinq.eclair.db

import fr.acinq.bitcoin.scalacompat.ByteVector32
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.eclair.{MilliSatoshi, TimestampMilli}
import fr.acinq.eclair.payment.relay.OnTheFlyFunding

/**
Expand All @@ -44,4 +45,13 @@ trait OnTheFlyFundingDb {
/** List the payment_hashes of pending proposals we funded for all remote nodes. */
def listPendingPayments(): Map[PublicKey, Set[ByteVector32]]

/** Add fee credit for the given remote node and return the updated fee credit. */
def addFeeCredit(nodeId: PublicKey, amount: MilliSatoshi, receivedAt: TimestampMilli = TimestampMilli.now()): MilliSatoshi

/** Return the amount owed to the given remote node as fee credit. */
def getFeeCredit(nodeId: PublicKey): MilliSatoshi

/** Remove fee credit for the given remote node and return the remaining fee credit. */
def removeFeeCredit(nodeId: PublicKey, amountUsed: MilliSatoshi): MilliSatoshi

}
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ package fr.acinq.eclair.db.pg

import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, TxId}
import fr.acinq.eclair.MilliSatoshiLong
import fr.acinq.eclair.db.Monitoring.Metrics.withMetrics
import fr.acinq.eclair.db.Monitoring.Tags.DbBackends
import fr.acinq.eclair.db.OnTheFlyFundingDb
import fr.acinq.eclair.db.pg.PgUtils.PgLock
import fr.acinq.eclair.payment.relay.OnTheFlyFunding
import fr.acinq.eclair.{MilliSatoshi, MilliSatoshiLong, TimestampMilli}
import scodec.bits.BitVector

import java.sql.Timestamp
Expand Down Expand Up @@ -53,6 +53,7 @@ class PgOnTheFlyFundingDb(implicit ds: DataSource, lock: PgLock) extends OnTheFl
statement.executeUpdate("CREATE SCHEMA IF NOT EXISTS on_the_fly_funding")
statement.executeUpdate("CREATE TABLE on_the_fly_funding.preimages (payment_hash TEXT NOT NULL PRIMARY KEY, preimage TEXT NOT NULL, received_at TIMESTAMP WITH TIME ZONE NOT NULL)")
statement.executeUpdate("CREATE TABLE on_the_fly_funding.pending (remote_node_id TEXT NOT NULL, payment_hash TEXT NOT NULL, channel_id TEXT NOT NULL, tx_id TEXT NOT NULL, funding_tx_index BIGINT NOT NULL, remaining_fees_msat BIGINT NOT NULL, proposed BYTEA NOT NULL, funded_at TIMESTAMP WITH TIME ZONE NOT NULL, PRIMARY KEY (remote_node_id, payment_hash))")
statement.executeUpdate("CREATE TABLE on_the_fly_funding.fee_credit (remote_node_id TEXT NOT NULL PRIMARY KEY, amount_msat BIGINT NOT NULL, updated_at TIMESTAMP WITH TIME ZONE NOT NULL)")
case Some(CURRENT_VERSION) => () // table is up-to-date, nothing to do
case Some(unknownVersion) => throw new RuntimeException(s"Unknown version of DB $DB_NAME found, version=$unknownVersion")
}
Expand Down Expand Up @@ -83,6 +84,7 @@ class PgOnTheFlyFundingDb(implicit ds: DataSource, lock: PgLock) extends OnTheFl
override def addPending(remoteNodeId: Crypto.PublicKey, pending: OnTheFlyFunding.Pending): Unit = withMetrics("on-the-fly-funding/add-pending", DbBackends.Postgres) {
pending.status match {
case _: OnTheFlyFunding.Status.Proposed => ()
case _: OnTheFlyFunding.Status.AddedToFeeCredit => ()
case status: OnTheFlyFunding.Status.Funded => withLock { pg =>
using(pg.prepareStatement("INSERT INTO on_the_fly_funding.pending (remote_node_id, payment_hash, channel_id, tx_id, funding_tx_index, remaining_fees_msat, proposed, funded_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING")) { statement =>
statement.setString(1, remoteNodeId.toHex)
Expand Down Expand Up @@ -144,4 +146,43 @@ class PgOnTheFlyFundingDb(implicit ds: DataSource, lock: PgLock) extends OnTheFl
}
}

override def addFeeCredit(nodeId: PublicKey, amount: MilliSatoshi, receivedAt: TimestampMilli): MilliSatoshi = withMetrics("on-the-fly-funding/add-fee-credit", DbBackends.Postgres) {
withLock { pg =>
using(pg.prepareStatement("INSERT INTO on_the_fly_funding.fee_credit(remote_node_id, amount_msat, updated_at) VALUES (?, ?, ?) ON CONFLICT (remote_node_id) DO UPDATE SET (amount_msat, updated_at) = (on_the_fly_funding.fee_credit.amount_msat + EXCLUDED.amount_msat, EXCLUDED.updated_at) RETURNING amount_msat")) { statement =>
statement.setString(1, nodeId.toHex)
statement.setLong(2, amount.toLong)
statement.setTimestamp(3, receivedAt.toSqlTimestamp)
statement.executeQuery().map(_.getLong("amount_msat").msat).headOption.getOrElse(0 msat)
}
}
}

override def getFeeCredit(nodeId: PublicKey): MilliSatoshi = withMetrics("on-the-fly-funding/get-fee-credit", DbBackends.Postgres) {
withLock { pg =>
using(pg.prepareStatement("SELECT amount_msat FROM on_the_fly_funding.fee_credit WHERE remote_node_id = ?")) { statement =>
statement.setString(1, nodeId.toHex)
statement.executeQuery().map(_.getLong("amount_msat").msat).headOption.getOrElse(0 msat)
}
}
}

override def removeFeeCredit(nodeId: PublicKey, amountUsed: MilliSatoshi): MilliSatoshi = withMetrics("on-the-fly-funding/remove-fee-credit", DbBackends.Postgres) {
withLock { pg =>
using(pg.prepareStatement("SELECT amount_msat FROM on_the_fly_funding.fee_credit WHERE remote_node_id = ?")) { statement =>
statement.setString(1, nodeId.toHex)
statement.executeQuery().map(_.getLong("amount_msat").msat).headOption match {
case Some(current) => using(pg.prepareStatement("UPDATE on_the_fly_funding.fee_credit SET (amount_msat, updated_at) = (?, ?) WHERE remote_node_id = ?")) { statement =>
val updated = (current - amountUsed).max(0 msat)
statement.setLong(1, updated.toLong)
statement.setTimestamp(2, Timestamp.from(Instant.now()))
statement.setString(3, nodeId.toHex)
statement.executeUpdate()
updated
}
case None => 0 msat
}
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import fr.acinq.eclair.db.Monitoring.Metrics.withMetrics
import fr.acinq.eclair.db.Monitoring.Tags.DbBackends
import fr.acinq.eclair.db.OnTheFlyFundingDb
import fr.acinq.eclair.payment.relay.OnTheFlyFunding
import fr.acinq.eclair.{MilliSatoshiLong, TimestampMilli}
import fr.acinq.eclair.{MilliSatoshi, MilliSatoshiLong, TimestampMilli}
import scodec.bits.BitVector

import java.sql.Connection
Expand All @@ -47,6 +47,7 @@ class SqliteOnTheFlyFundingDb(val sqlite: Connection) extends OnTheFlyFundingDb
case None =>
statement.executeUpdate("CREATE TABLE on_the_fly_funding_preimages (payment_hash BLOB NOT NULL PRIMARY KEY, preimage BLOB NOT NULL, received_at INTEGER NOT NULL)")
statement.executeUpdate("CREATE TABLE on_the_fly_funding_pending (remote_node_id BLOB NOT NULL, payment_hash BLOB NOT NULL, channel_id BLOB NOT NULL, tx_id BLOB NOT NULL, funding_tx_index INTEGER NOT NULL, remaining_fees_msat INTEGER NOT NULL, proposed BLOB NOT NULL, funded_at INTEGER NOT NULL, PRIMARY KEY (remote_node_id, payment_hash))")
statement.executeUpdate("CREATE TABLE fee_credit (remote_node_id BLOB NOT NULL PRIMARY KEY, amount_msat INTEGER NOT NULL, updated_at INTEGER NOT NULL)")
case Some(CURRENT_VERSION) => () // table is up-to-date, nothing to do
case Some(unknownVersion) => throw new RuntimeException(s"Unknown version of DB $DB_NAME found, version=$unknownVersion")
}
Expand All @@ -72,6 +73,7 @@ class SqliteOnTheFlyFundingDb(val sqlite: Connection) extends OnTheFlyFundingDb
override def addPending(remoteNodeId: Crypto.PublicKey, pending: OnTheFlyFunding.Pending): Unit = withMetrics("on-the-fly-funding/add-pending", DbBackends.Sqlite) {
pending.status match {
case _: OnTheFlyFunding.Status.Proposed => ()
case _: OnTheFlyFunding.Status.AddedToFeeCredit => ()
case status: OnTheFlyFunding.Status.Funded =>
using(sqlite.prepareStatement("INSERT OR IGNORE INTO on_the_fly_funding_pending (remote_node_id, payment_hash, channel_id, tx_id, funding_tx_index, remaining_fees_msat, proposed, funded_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")) { statement =>
statement.setBytes(1, remoteNodeId.value.toArray)
Expand Down Expand Up @@ -126,4 +128,50 @@ class SqliteOnTheFlyFundingDb(val sqlite: Connection) extends OnTheFlyFundingDb
}
}

override def addFeeCredit(nodeId: PublicKey, amount: MilliSatoshi, receivedAt: TimestampMilli): MilliSatoshi = withMetrics("on-the-fly-funding/add-fee-credit", DbBackends.Sqlite) {
using(sqlite.prepareStatement("SELECT amount_msat FROM fee_credit WHERE remote_node_id = ?")) { statement =>
statement.setBytes(1, nodeId.value.toArray)
statement.executeQuery().map(_.getLong("amount_msat").msat).headOption match {
case Some(current) => using(sqlite.prepareStatement("UPDATE fee_credit SET (amount_msat, updated_at) = (?, ?) WHERE remote_node_id = ?")) { statement =>
statement.setLong(1, (current + amount).toLong)
statement.setLong(2, receivedAt.toLong)
statement.setBytes(3, nodeId.value.toArray)
statement.executeUpdate()
amount + current
}
case None => using(sqlite.prepareStatement("INSERT OR IGNORE INTO fee_credit(remote_node_id, amount_msat, updated_at) VALUES (?, ?, ?)")) { statement =>
statement.setBytes(1, nodeId.value.toArray)
statement.setLong(2, amount.toLong)
statement.setLong(3, receivedAt.toLong)
statement.executeUpdate()
amount
}
}
}
}

override def getFeeCredit(nodeId: PublicKey): MilliSatoshi = withMetrics("on-the-fly-funding/get-fee-credit", DbBackends.Sqlite) {
using(sqlite.prepareStatement("SELECT amount_msat FROM fee_credit WHERE remote_node_id = ?")) { statement =>
statement.setBytes(1, nodeId.value.toArray)
statement.executeQuery().map(_.getLong("amount_msat").msat).headOption.getOrElse(0 msat)
}
}

override def removeFeeCredit(nodeId: PublicKey, amountUsed: MilliSatoshi): MilliSatoshi = withMetrics("on-the-fly-funding/remove-fee-credit", DbBackends.Sqlite) {
using(sqlite.prepareStatement("SELECT amount_msat FROM fee_credit WHERE remote_node_id = ?")) { statement =>
statement.setBytes(1, nodeId.value.toArray)
statement.executeQuery().map(_.getLong("amount_msat").msat).headOption match {
case Some(current) => using(sqlite.prepareStatement("UPDATE fee_credit SET (amount_msat, updated_at) = (?, ?) WHERE remote_node_id = ?")) { statement =>
val updated = (current - amountUsed).max(0 msat)
statement.setLong(1, updated.toLong)
statement.setLong(2, TimestampMilli.now().toLong)
statement.setBytes(3, nodeId.value.toArray)
statement.executeUpdate()
updated
}
case None => 0 msat
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ object Monitoring {
val Rejected = "rejected"
val Expired = "expired"
val Timeout = "timeout"
val AddedToFeeCredit = "added-to-fee-credit"
val Funded = "funded"
val RelaySucceeded = "relay-succeeded"

Expand Down
Loading

0 comments on commit b1e18a8

Please sign in to comment.