diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala
index 7d4e965a6f..c9886b031e 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala
@@ -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,
@@ -358,7 +366,8 @@ object Features {
     TrampolinePaymentPrototype,
     AsyncPaymentPrototype,
     SplicePrototype,
-    OnTheFlyFunding
+    OnTheFlyFunding,
+    FundingFeeCredit
   )
 
   // Features may depend on other features, as specified in Bolt 9.
@@ -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)
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala
index 4771371e3f..033899eeb5 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala
@@ -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, isChannelCreation = true, open.requestFunding_opt, addFunding_opt.flatMap(_.rates_opt))
+      willFund_opt <- LiquidityAds.validateRequest(nodeParams.privateKey, open.temporaryChannelId, fundingScript, open.fundingFeerate, isChannelCreation = true, open.requestFunding_opt, addFunding_opt.flatMap(_.rates_opt), open.useFeeCredit_opt)
     } yield (channelFeatures, script_opt, willFund_opt)
   }
 
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala
index 056cbbed46..5d73fb0241 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala
@@ -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, isChannelCreation = false, msg.requestFunding_opt, nodeParams.willFundRates_opt) match {
+            LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, isChannelCreation = false, 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)
@@ -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,
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala
index e60549fd13..46daea5e66 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala
@@ -180,6 +180,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers {
             Some(ChannelTlv.ChannelTypeTlv(d.init.channelType)),
             if (d.init.requireConfirmedInputs) 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(
@@ -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, isChannelCreation = true, msg.requestFunding_opt, nodeParams.willFundRates_opt) match {
+              LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, isChannelCreation = true, 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)
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala
index 10255527a2..0b78f2de31 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala
@@ -37,7 +37,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}
@@ -157,13 +157,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)
     }
   }
 
@@ -744,6 +749,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.liquidity.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
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala
index 662c842bb5..d1ff3487ea 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala
@@ -463,4 +463,19 @@ case class DualLiquidityDb(primary: LiquidityDb, secondary: LiquidityDb) extends
     primary.getOnTheFlyFundingPreimage(paymentHash)
   }
 
+  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)
+  }
+
 }
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/LiquidityDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/LiquidityDb.scala
index fb2217fe28..3feacaf1dc 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/db/LiquidityDb.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/LiquidityDb.scala
@@ -20,6 +20,7 @@ import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
 import fr.acinq.bitcoin.scalacompat.{ByteVector32, TxId}
 import fr.acinq.eclair.channel.{ChannelLiquidityPurchased, LiquidityPurchase}
 import fr.acinq.eclair.payment.relay.OnTheFlyFunding
+import fr.acinq.eclair.{MilliSatoshi, TimestampMilli}
 
 /**
  * Created by t-bast on 13/09/2024. 
@@ -57,4 +58,13 @@ trait LiquidityDb {
   /** Check if we received the preimage for the given payment hash of an on-the-fly payment. */
   def getOnTheFlyFundingPreimage(paymentHash: ByteVector32): Option[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
+
 }
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgLiquidityDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgLiquidityDb.scala
index 279b7ffb40..a9629aaaca 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgLiquidityDb.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgLiquidityDb.scala
@@ -25,7 +25,7 @@ import fr.acinq.eclair.db.Monitoring.Tags.DbBackends
 import fr.acinq.eclair.db.pg.PgUtils.PgLock.NoLock.withLock
 import fr.acinq.eclair.payment.relay.OnTheFlyFunding
 import fr.acinq.eclair.wire.protocol.LiquidityAds
-import fr.acinq.eclair.{MilliSatoshi, MilliSatoshiLong}
+import fr.acinq.eclair.{MilliSatoshi, MilliSatoshiLong, TimestampMilli}
 import grizzled.slf4j.Logging
 import scodec.bits.BitVector
 
@@ -58,6 +58,7 @@ class PgLiquidityDb(implicit ds: DataSource) extends LiquidityDb with Logging {
           // On-the-fly funding.
           statement.executeUpdate("CREATE TABLE liquidity.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 liquidity.pending_on_the_fly_funding (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 (node_id, payment_hash))")
+          statement.executeUpdate("CREATE TABLE liquidity.fee_credits (node_id TEXT NOT NULL PRIMARY KEY, amount_msat BIGINT NOT NULL, updated_at TIMESTAMP WITH TIME ZONE NOT NULL)")
           // Indexes.
           statement.executeUpdate("CREATE INDEX liquidity_purchases_node_id_idx ON liquidity.purchases(node_id)")
         case Some(CURRENT_VERSION) => () // table is up-to-date, nothing to do
@@ -129,6 +130,7 @@ class PgLiquidityDb(implicit ds: DataSource) extends LiquidityDb with Logging {
   override def addPendingOnTheFlyFunding(remoteNodeId: Crypto.PublicKey, pending: OnTheFlyFunding.Pending): Unit = withMetrics("liquidity/add-pending-on-the-fly-funding", 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 liquidity.pending_on_the_fly_funding (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)
@@ -237,4 +239,43 @@ class PgLiquidityDb(implicit ds: DataSource) extends LiquidityDb with Logging {
     }
   }
 
+  override def addFeeCredit(nodeId: PublicKey, amount: MilliSatoshi, receivedAt: TimestampMilli): MilliSatoshi = withMetrics("liquidity/add-fee-credit", DbBackends.Postgres) {
+    withLock { pg =>
+      using(pg.prepareStatement("INSERT INTO liquidity.fee_credits(node_id, amount_msat, updated_at) VALUES (?, ?, ?) ON CONFLICT (node_id) DO UPDATE SET (amount_msat, updated_at) = (liquidity.fee_credits.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("liquidity/get-fee-credit", DbBackends.Postgres) {
+    withLock { pg =>
+      using(pg.prepareStatement("SELECT amount_msat FROM liquidity.fee_credits WHERE 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("liquidity/remove-fee-credit", DbBackends.Postgres) {
+    withLock { pg =>
+      using(pg.prepareStatement("SELECT amount_msat FROM liquidity.fee_credits WHERE node_id = ?")) { statement =>
+        statement.setString(1, nodeId.toHex)
+        statement.executeQuery().map(_.getLong("amount_msat").msat).headOption match {
+          case Some(current) => using(pg.prepareStatement("UPDATE liquidity.fee_credits SET (amount_msat, updated_at) = (?, ?) WHERE 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
+        }
+      }
+    }
+  }
+
 }
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteLiquidityDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteLiquidityDb.scala
index ffaa4af5c6..c2796ba588 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteLiquidityDb.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteLiquidityDb.scala
@@ -53,6 +53,7 @@ class SqliteLiquidityDb(val sqlite: Connection) extends LiquidityDb with Logging
         // On-the-fly funding.
         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 (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 (node_id, payment_hash))")
+        statement.executeUpdate("CREATE TABLE fee_credits (node_id BLOB NOT NULL PRIMARY KEY, amount_msat INTEGER NOT NULL, updated_at INTEGER NOT NULL)")
         // Indexes.
         statement.executeUpdate("CREATE INDEX liquidity_purchases_node_id_idx ON liquidity_purchases(node_id)")
       case Some(CURRENT_VERSION) => () // table is up-to-date, nothing to do
@@ -117,6 +118,7 @@ class SqliteLiquidityDb(val sqlite: Connection) extends LiquidityDb with Logging
   override def addPendingOnTheFlyFunding(remoteNodeId: Crypto.PublicKey, pending: OnTheFlyFunding.Pending): Unit = withMetrics("liquidity/add-pending-on-the-fly-funding", 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 (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)
@@ -212,4 +214,50 @@ class SqliteLiquidityDb(val sqlite: Connection) extends LiquidityDb with Logging
     }
   }
 
+  override def addFeeCredit(nodeId: PublicKey, amount: MilliSatoshi, receivedAt: TimestampMilli): MilliSatoshi = withMetrics("liquidity/add-fee-credit", DbBackends.Sqlite) {
+    using(sqlite.prepareStatement("SELECT amount_msat FROM fee_credits WHERE 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_credits SET (amount_msat, updated_at) = (?, ?) WHERE 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_credits(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("liquidity/get-fee-credit", DbBackends.Sqlite) {
+    using(sqlite.prepareStatement("SELECT amount_msat FROM fee_credits WHERE 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("liquidity/remove-fee-credit", DbBackends.Sqlite) {
+    using(sqlite.prepareStatement("SELECT amount_msat FROM fee_credits WHERE 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_credits SET (amount_msat, updated_at) = (?, ?) WHERE 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
+      }
+    }
+  }
+
 }
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Monitoring.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Monitoring.scala
index d60a10cfe7..aec4fdef9a 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Monitoring.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Monitoring.scala
@@ -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"
 
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala
index d5a61aa119..8714ac9b5a 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala
@@ -84,7 +84,7 @@ object OpenChannelInterceptor {
       }
     }
 
-  def makeChannelParams(nodeParams: NodeParams, initFeatures: Features[InitFeature], upfrontShutdownScript_opt: Option[ByteVector], walletStaticPaymentBasepoint_opt: Option[PublicKey], isChannelOpener: Boolean, dualFunded: Boolean, fundingAmount: Satoshi, unlimitedMaxHtlcValueInFlight: Boolean): LocalParams = {
+  def makeChannelParams(nodeParams: NodeParams, initFeatures: Features[InitFeature], upfrontShutdownScript_opt: Option[ByteVector], walletStaticPaymentBasepoint_opt: Option[PublicKey], isChannelOpener: Boolean, paysCommitTxFees: Boolean, dualFunded: Boolean, fundingAmount: Satoshi, unlimitedMaxHtlcValueInFlight: Boolean): LocalParams = {
     val maxHtlcValueInFlightMsat = if (unlimitedMaxHtlcValueInFlight) {
       // We don't want to impose limits on the amount in flight, typically to allow fully emptying the channel.
       21e6.btc.toMilliSatoshi
@@ -104,7 +104,7 @@ object OpenChannelInterceptor {
       toSelfDelay = nodeParams.channelConf.toRemoteDelay, // we choose their delay
       maxAcceptedHtlcs = nodeParams.channelConf.maxAcceptedHtlcs,
       isChannelOpener = isChannelOpener,
-      paysCommitTxFees = isChannelOpener,
+      paysCommitTxFees = paysCommitTxFees,
       upfrontShutdownScript_opt = upfrontShutdownScript_opt,
       walletStaticPaymentBasepoint = walletStaticPaymentBasepoint_opt,
       initFeatures = initFeatures
@@ -142,7 +142,7 @@ private class OpenChannelInterceptor(peer: ActorRef[Any],
       val channelType = request.open.channelType_opt.getOrElse(ChannelTypes.defaultFromFeatures(request.localFeatures, request.remoteFeatures, channelFlags.announceChannel))
       val dualFunded = Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.DualFunding)
       val upfrontShutdownScript = Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.UpfrontShutdownScript)
-      val localParams = createLocalParams(nodeParams, request.localFeatures, upfrontShutdownScript, channelType, isChannelOpener = true, dualFunded = dualFunded, request.open.fundingAmount, request.open.disableMaxHtlcValueInFlight)
+      val localParams = createLocalParams(nodeParams, request.localFeatures, upfrontShutdownScript, channelType, isChannelOpener = true, paysCommitTxFees = true, dualFunded = dualFunded, request.open.fundingAmount, request.open.disableMaxHtlcValueInFlight)
       peer ! Peer.SpawnChannelInitiator(request.replyTo, request.open, ChannelConfig.standard, channelType, localParams)
       waitForRequest()
     }
@@ -161,18 +161,24 @@ private class OpenChannelInterceptor(peer: ActorRef[Any],
       case Right(channelType) =>
         val dualFunded = Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.DualFunding)
         val upfrontShutdownScript = Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.UpfrontShutdownScript)
-        val localParams = createLocalParams(nodeParams, request.localFeatures, upfrontShutdownScript, channelType, isChannelOpener = false, dualFunded = dualFunded, request.fundingAmount, disableMaxHtlcValueInFlight = false)
         // We only accept paying the commit fees if:
         //  - our peer supports on-the-fly funding, indicating that they're a mobile wallet
         //  - they are purchasing liquidity for this channel
         val nonInitiatorPaysCommitTxFees = request.channelFlags.nonInitiatorPaysCommitFees &&
           Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.OnTheFlyFunding) &&
           request.open.fold(_ => false, _.requestFunding_opt.isDefined)
-        if (nonInitiatorPaysCommitTxFees) {
-          checkRateLimits(request, channelType, localParams.copy(paysCommitTxFees = true))
-        } else {
-          checkRateLimits(request, channelType, localParams)
-        }
+        val localParams = createLocalParams(
+          nodeParams,
+          request.localFeatures,
+          upfrontShutdownScript,
+          channelType,
+          isChannelOpener = false,
+          paysCommitTxFees = nonInitiatorPaysCommitTxFees,
+          dualFunded = dualFunded,
+          fundingAmount = request.fundingAmount,
+          disableMaxHtlcValueInFlight = false
+        )
+        checkRateLimits(request, channelType, localParams)
       case Left(ex) =>
         context.log.warn(s"ignoring remote channel open: ${ex.getMessage}")
         sendFailure(ex.getMessage, request)
@@ -308,13 +314,14 @@ private class OpenChannelInterceptor(peer: ActorRef[Any],
     }
   }
 
-  private def createLocalParams(nodeParams: NodeParams, initFeatures: Features[InitFeature], upfrontShutdownScript: Boolean, channelType: SupportedChannelType, isChannelOpener: Boolean, dualFunded: Boolean, fundingAmount: Satoshi, disableMaxHtlcValueInFlight: Boolean): LocalParams = {
+  private def createLocalParams(nodeParams: NodeParams, initFeatures: Features[InitFeature], upfrontShutdownScript: Boolean, channelType: SupportedChannelType, isChannelOpener: Boolean, paysCommitTxFees: Boolean, dualFunded: Boolean, fundingAmount: Satoshi, disableMaxHtlcValueInFlight: Boolean): LocalParams = {
     val pubkey_opt = if (upfrontShutdownScript || channelType.paysDirectlyToWallet) Some(wallet.getP2wpkhPubkey()) else None
     makeChannelParams(
       nodeParams, initFeatures,
       if (upfrontShutdownScript) Some(Script.write(Script.pay2wpkh(pubkey_opt.get))) else None,
       if (channelType.paysDirectlyToWallet) Some(pubkey_opt.get) else None,
       isChannelOpener = isChannelOpener,
+      paysCommitTxFees = paysCommitTxFees,
       dualFunded = dualFunded,
       fundingAmount,
       disableMaxHtlcValueInFlight
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala
index a11ccdfff6..68e31f63ba 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala
@@ -44,8 +44,7 @@ import fr.acinq.eclair.remote.EclairInternalsSerializer.RemoteTypes
 import fr.acinq.eclair.router.Router
 import fr.acinq.eclair.wire.protocol
 import fr.acinq.eclair.wire.protocol.FailureMessageCodecs.createBadOnionFailure
-import fr.acinq.eclair.wire.protocol.LiquidityAds.PaymentDetails
-import fr.acinq.eclair.wire.protocol.{Error, HasChannelId, HasTemporaryChannelId, LightningMessage, LiquidityAds, NodeAddress, OnTheFlyFundingFailureMessage, OnionMessage, OnionRoutingPacket, RoutingMessage, SpliceInit, UnknownMessage, Warning, WillAddHtlc, WillFailHtlc, WillFailMalformedHtlc}
+import fr.acinq.eclair.wire.protocol.{AddFeeCredit, ChannelTlv, CurrentFeeCredit, Error, HasChannelId, HasTemporaryChannelId, LightningMessage, LiquidityAds, NodeAddress, OnTheFlyFundingFailureMessage, OnionMessage, OnionRoutingPacket, RoutingMessage, SpliceInit, TlvStream, UnknownMessage, Warning, WillAddHtlc, WillFailHtlc, WillFailMalformedHtlc}
 
 /**
  * This actor represents a logical peer. There is one [[Peer]] per unique remote node id at all time.
@@ -69,6 +68,7 @@ class Peer(val nodeParams: NodeParams,
   import Peer._
 
   private var pendingOnTheFlyFunding = Map.empty[ByteVector32, OnTheFlyFunding.Pending]
+  private var feeCredit = Option.empty[MilliSatoshi]
 
   context.system.eventStream.subscribe(self, classOf[CurrentFeerates])
   context.system.eventStream.subscribe(self, classOf[CurrentBlockHeight])
@@ -100,7 +100,7 @@ class Peer(val nodeParams: NodeParams,
       val channelIds = d.channels.filter(_._2 == actor).keys
       log.info(s"channel closed: channelId=${channelIds.mkString("/")}")
       val channels1 = d.channels -- channelIds
-      if (channels1.isEmpty && !pendingSignedOnTheFlyFunding()) {
+      if (channels1.isEmpty && canForgetPendingOnTheFlyFunding()) {
         log.info("that was the last open channel")
         context.system.eventStream.publish(LastChannelClosed(self, remoteNodeId))
         // We have no existing channels or pending signed transaction, we can forget about this peer.
@@ -113,7 +113,7 @@ class Peer(val nodeParams: NodeParams,
       Logs.withMdc(diagLog)(Logs.mdc(category_opt = Some(Logs.LogCategory.CONNECTION))) {
         log.debug("connection lost while negotiating connection")
       }
-      if (d.channels.isEmpty && !pendingSignedOnTheFlyFunding()) {
+      if (d.channels.isEmpty && canForgetPendingOnTheFlyFunding()) {
         // We have no existing channels or pending signed transaction, we can forget about this peer.
         stopPeer()
       } else {
@@ -214,7 +214,7 @@ class Peer(val nodeParams: NodeParams,
       case Event(SpawnChannelNonInitiator(open, channelConfig, channelType, addFunding_opt, localParams, peerConnection), d: ConnectedData) =>
         val temporaryChannelId = open.fold(_.temporaryChannelId, _.temporaryChannelId)
         if (peerConnection == d.peerConnection) {
-          OnTheFlyFunding.validateOpen(open, pendingOnTheFlyFunding) match {
+          OnTheFlyFunding.validateOpen(open, pendingOnTheFlyFunding, feeCredit.getOrElse(0 msat)) match {
             case reject: OnTheFlyFunding.ValidationResult.Reject =>
               log.warning("rejecting on-the-fly channel: {}", reject.cancel.toAscii)
               self ! Peer.OutgoingMessage(reject.cancel, d.peerConnection)
@@ -231,7 +231,10 @@ class Peer(val nodeParams: NodeParams,
                 case Right(open) =>
                   val requireConfirmedInputs = nodeParams.channelConf.requireConfirmedInputsForDualFunding
                   channel ! INPUT_INIT_CHANNEL_NON_INITIATOR(open.temporaryChannelId, addFunding_opt, dualFunded = true, None, requireConfirmedInputs, localParams, d.peerConnection, d.remoteInit, channelConfig, channelType)
-                  channel ! open
+                  accept.useFeeCredit_opt match {
+                    case Some(useFeeCredit) => channel ! open.copy(tlvStream = TlvStream(open.tlvStream.records + ChannelTlv.UseFeeCredit(useFeeCredit)))
+                    case None => channel ! open
+                  }
               }
               fulfillOnTheFlyFundingHtlcs(accept.preimages)
               stay() using d.copy(channels = d.channels + (TemporaryChannelId(temporaryChannelId) -> channel))
@@ -263,6 +266,11 @@ class Peer(val nodeParams: NodeParams,
                   proposed = pending.proposed :+ OnTheFlyFunding.Proposal(htlc, cmd.upstream),
                   status = OnTheFlyFunding.Status.Proposed(timer)
                 )
+              case status: OnTheFlyFunding.Status.AddedToFeeCredit =>
+                log.info("received extra payment for on-the-fly funding that was added to fee credit (payment_hash={}, amount={})", cmd.paymentHash, cmd.amount)
+                val proposal = OnTheFlyFunding.Proposal(htlc, cmd.upstream)
+                proposal.createFulfillCommands(status.preimage).foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
+                pending.copy(proposed = pending.proposed :+ proposal)
               case status: OnTheFlyFunding.Status.Funded =>
                 log.info("received extra payment for on-the-fly funding that has already been funded with txId={} (payment_hash={}, amount={})", status.txId, cmd.paymentHash, cmd.amount)
                 pending.copy(proposed = pending.proposed :+ OnTheFlyFunding.Proposal(htlc, cmd.upstream))
@@ -300,6 +308,9 @@ class Peer(val nodeParams: NodeParams,
                     log.warning("ignoring will_fail_htlc: no matching proposal for id={}", msg.id)
                     self ! Peer.OutgoingMessage(Warning(s"ignoring will_fail_htlc: no matching proposal for id=${msg.id}"), d.peerConnection)
                 }
+              case _: OnTheFlyFunding.Status.AddedToFeeCredit =>
+                log.warning("ignoring will_fail_htlc: on-the-fly funding already added to fee credit")
+                self ! Peer.OutgoingMessage(Warning("ignoring will_fail_htlc: on-the-fly funding already added to fee credit"), d.peerConnection)
               case status: OnTheFlyFunding.Status.Funded =>
                 log.warning("ignoring will_fail_htlc: on-the-fly funding already signed with txId={}", status.txId)
                 self ! Peer.OutgoingMessage(Warning(s"ignoring will_fail_htlc: on-the-fly funding already signed with txId=${status.txId}"), d.peerConnection)
@@ -320,6 +331,8 @@ class Peer(val nodeParams: NodeParams,
                 Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Expired).increment()
                 pendingOnTheFlyFunding -= timeout.paymentHash
                 self ! Peer.OutgoingMessage(Warning(s"on-the-fly funding proposal timed out for payment_hash=${timeout.paymentHash}"), d.peerConnection)
+              case _: OnTheFlyFunding.Status.AddedToFeeCredit =>
+                log.warning("ignoring on-the-fly funding proposal timeout, already added to fee credit")
               case status: OnTheFlyFunding.Status.Funded =>
                 log.warning("ignoring on-the-fly funding proposal timeout, already funded with txId={}", status.txId)
             }
@@ -328,17 +341,56 @@ class Peer(val nodeParams: NodeParams,
         }
         stay()
 
+      case Event(msg: AddFeeCredit, d: ConnectedData) if !nodeParams.features.hasFeature(Features.FundingFeeCredit) =>
+        self ! Peer.OutgoingMessage(Warning(s"ignoring add_fee_credit for payment_hash=${Crypto.sha256(msg.preimage)}, ${Features.FundingFeeCredit.rfcName} is not supported"), d.peerConnection)
+        stay()
+
+      case Event(msg: AddFeeCredit, d: ConnectedData) =>
+        val paymentHash = Crypto.sha256(msg.preimage)
+        pendingOnTheFlyFunding.get(paymentHash) match {
+          case Some(pending) =>
+            pending.status match {
+              case status: OnTheFlyFunding.Status.Proposed =>
+                feeCredit = Some(nodeParams.db.liquidity.addFeeCredit(remoteNodeId, pending.amountOut))
+                log.info("received add_fee_credit for payment_hash={}, adding {} to fee credit (total = {})", paymentHash, pending.amountOut, feeCredit)
+                status.timer.cancel()
+                Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.AddedToFeeCredit).increment()
+                pending.createFulfillCommands(msg.preimage).foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
+                self ! Peer.OutgoingMessage(CurrentFeeCredit(nodeParams.chainHash, feeCredit.getOrElse(0 msat)), d.peerConnection)
+                pendingOnTheFlyFunding += (paymentHash -> pending.copy(status = OnTheFlyFunding.Status.AddedToFeeCredit(msg.preimage)))
+              case _: OnTheFlyFunding.Status.AddedToFeeCredit =>
+                log.warning("ignoring duplicate add_fee_credit for payment_hash={}", paymentHash)
+                // We already fulfilled upstream HTLCs, there is nothing else to do.
+                self ! Peer.OutgoingMessage(Warning(s"ignoring add_fee_credit: on-the-fly proposal already funded for payment_hash=$paymentHash"), d.peerConnection)
+              case _: OnTheFlyFunding.Status.Funded =>
+                log.warning("ignoring add_fee_credit for funded on-the-fly proposal (payment_hash={})", paymentHash)
+                // They seem to be malicious, so let's fulfill upstream HTLCs for safety.
+                pending.createFulfillCommands(msg.preimage).foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
+                self ! Peer.OutgoingMessage(Warning(s"ignoring add_fee_credit: on-the-fly proposal already funded for payment_hash=$paymentHash"), d.peerConnection)
+            }
+          case None =>
+            log.warning("ignoring add_fee_credit for unknown payment_hash={}", paymentHash)
+            self ! Peer.OutgoingMessage(Warning(s"ignoring add_fee_credit: unknown payment_hash=$paymentHash"), d.peerConnection)
+            // This may happen if the remote node is very slow and the timeout was reached before receiving their message.
+            // We sent the current fee credit to let them detect it and reconcile their state.
+            self ! Peer.OutgoingMessage(CurrentFeeCredit(nodeParams.chainHash, feeCredit.getOrElse(0 msat)), d.peerConnection)
+        }
+        stay()
+
       case Event(msg: SpliceInit, d: ConnectedData) =>
         d.channels.get(FinalChannelId(msg.channelId)) match {
           case Some(channel) =>
-            OnTheFlyFunding.validateSplice(msg, nodeParams.channelConf.htlcMinimum, pendingOnTheFlyFunding) match {
+            OnTheFlyFunding.validateSplice(msg, nodeParams.channelConf.htlcMinimum, pendingOnTheFlyFunding, feeCredit.getOrElse(0 msat)) match {
               case reject: OnTheFlyFunding.ValidationResult.Reject =>
                 log.warning("rejecting on-the-fly splice: {}", reject.cancel.toAscii)
                 self ! Peer.OutgoingMessage(reject.cancel, d.peerConnection)
                 cancelUnsignedOnTheFlyFunding(reject.paymentHashes)
               case accept: OnTheFlyFunding.ValidationResult.Accept =>
                 fulfillOnTheFlyFundingHtlcs(accept.preimages)
-                channel forward msg
+                accept.useFeeCredit_opt match {
+                  case Some(useFeeCredit) => channel forward msg.copy(tlvStream = TlvStream(msg.tlvStream.records + ChannelTlv.UseFeeCredit(useFeeCredit)))
+                  case None => channel forward msg
+                }
             }
           case None => replyUnknownChannel(d.peerConnection, msg.channelId)
         }
@@ -349,6 +401,7 @@ class Peer(val nodeParams: NodeParams,
           case (paymentHash, pending) =>
             pending.status match {
               case _: OnTheFlyFunding.Status.Proposed => ()
+              case _: OnTheFlyFunding.Status.AddedToFeeCredit => ()
               case status: OnTheFlyFunding.Status.Funded =>
                 context.child(paymentHash.toHex) match {
                   case Some(_) => log.debug("already relaying payment_hash={}", paymentHash)
@@ -396,7 +449,7 @@ class Peer(val nodeParams: NodeParams,
         Logs.withMdc(diagLog)(Logs.mdc(category_opt = Some(Logs.LogCategory.CONNECTION))) {
           log.debug("connection lost")
         }
-        if (d.channels.isEmpty && !pendingSignedOnTheFlyFunding()) {
+        if (d.channels.isEmpty && canForgetPendingOnTheFlyFunding()) {
           // We have no existing channels or pending signed transaction, we can forget about this peer.
           stopPeer()
         } else {
@@ -506,16 +559,20 @@ class Peer(val nodeParams: NodeParams,
       val expired = pendingOnTheFlyFunding.filter {
         case (_, pending) => pending.proposed.exists(_.htlc.expiry.blockHeight <= current.blockHeight)
       }
-      expired.foreach {
-        case (paymentHash, pending) =>
-          log.warning("will_add_htlc expired for payment_hash={}, our peer may be malicious", paymentHash)
-          Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Timeout).increment()
-          pending.createFailureCommands().foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
-      }
       expired.foreach {
         case (paymentHash, pending) => pending.status match {
-          case _: OnTheFlyFunding.Status.Proposed => ()
-          case _: OnTheFlyFunding.Status.Funded => nodeParams.db.liquidity.removePendingOnTheFlyFunding(remoteNodeId, paymentHash)
+          case _: OnTheFlyFunding.Status.Proposed =>
+            log.warning("proposed will_add_htlc expired for payment_hash={}", paymentHash)
+            Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Timeout).increment()
+            pending.createFailureCommands().foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
+          case _: OnTheFlyFunding.Status.AddedToFeeCredit =>
+            // Nothing to do, we already fulfilled the upstream HTLCs.
+            log.debug("forgetting will_add_htlc added to fee credit for payment_hash={}", paymentHash)
+          case _: OnTheFlyFunding.Status.Funded =>
+            log.warning("funded will_add_htlc expired for payment_hash={}, our peer may be malicious", paymentHash)
+            Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Timeout).increment()
+            pending.createFailureCommands().foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
+            nodeParams.db.liquidity.removePendingOnTheFlyFunding(remoteNodeId, paymentHash)
         }
       }
       pendingOnTheFlyFunding = pendingOnTheFlyFunding.removedAll(expired.keys)
@@ -524,22 +581,34 @@ class Peer(val nodeParams: NodeParams,
         case _ => stay()
       }
 
-    case Event(e: LiquidityPurchaseSigned, _: ConnectedData) =>
+    case Event(e: LiquidityPurchaseSigned, d: ConnectedData) =>
+      // If that liquidity purchase was partially paid with fee credit, we will deduce it from what our peer owes us
+      // and remove the corresponding amount from our peer's credit.
+      // Note that since we only allow a single channel per user when on-the-fly funding is used, and it's not possible
+      // to request a splice while one is already in progress, it's safe to only remove fee credit once the funding
+      // transaction has been signed.
+      val feeCreditUsed = e.purchase match {
+        case _: LiquidityAds.Purchase.Standard => 0 msat
+        case p: LiquidityAds.Purchase.WithFeeCredit =>
+          feeCredit = Some(nodeParams.db.liquidity.removeFeeCredit(remoteNodeId, p.feeCreditUsed))
+          self ! OutgoingMessage(CurrentFeeCredit(nodeParams.chainHash, feeCredit.getOrElse(0 msat)), d.peerConnection)
+          p.feeCreditUsed
+      }
       // We signed a liquidity purchase from our peer. At that point we're not 100% sure yet it will succeed: if
       // we disconnect before our peer sends their signature, the funding attempt may be cancelled when reconnecting.
       // If that happens, the on-the-fly proposal will stay in our state until we reach the CLTV expiry, at which
       // point we will forget it and fail the upstream HTLCs. This is also what would happen if we successfully
       // funded the channel, but it closed before we could relay the HTLCs.
-      val (paymentHashes, fees) = e.purchase.paymentDetails match {
-        case PaymentDetails.FromChannelBalance => (Nil, 0 sat)
-        case p: PaymentDetails.FromChannelBalanceForFutureHtlc => (p.paymentHashes, 0 sat)
-        case p: PaymentDetails.FromFutureHtlc => (p.paymentHashes, e.purchase.fees.total)
-        case p: PaymentDetails.FromFutureHtlcWithPreimage => (p.preimages.map(preimage => Crypto.sha256(preimage)), e.purchase.fees.total)
+      val (paymentHashes, feesOwed) = e.purchase.paymentDetails match {
+        case LiquidityAds.PaymentDetails.FromChannelBalance => (Nil, 0 msat)
+        case p: LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc => (p.paymentHashes, 0 msat)
+        case p: LiquidityAds.PaymentDetails.FromFutureHtlc => (p.paymentHashes, e.purchase.fees.total - feeCreditUsed)
+        case p: LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage => (p.preimages.map(preimage => Crypto.sha256(preimage)), e.purchase.fees.total - feeCreditUsed)
       }
       // We split the fees across payments. We could dynamically re-split depending on whether some payments are failed
       // instead of fulfilled, but that's overkill: if our peer fails one of those payment, they're likely malicious
       // and will fail anyway, even if we try to be clever with fees splitting.
-      var remainingFees = fees.toMilliSatoshi
+      var remainingFees = feesOwed.max(0 msat)
       pendingOnTheFlyFunding
         .filter { case (paymentHash, _) => paymentHashes.contains(paymentHash) }
         .values.toSeq
@@ -556,6 +625,17 @@ class Peer(val nodeParams: NodeParams,
               Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Funded).increment()
               nodeParams.db.liquidity.addPendingOnTheFlyFunding(remoteNodeId, payment1)
               pendingOnTheFlyFunding += payment.paymentHash -> payment1
+            case _: OnTheFlyFunding.Status.AddedToFeeCredit =>
+              log.warning("liquidity purchase was signed for payment_hash={} that was also added to fee credit: our peer may be malicious", payment.paymentHash)
+              // Our peer tried to concurrently get a channel funded *and* add the same payment to its fee credit.
+              // We've already signed the funding transaction so we can't abort, but we have also received the preimage
+              // and fulfilled the upstream HTLCs: we simply won't forward the matching HTLCs on the funded channel.
+              // Instead of being paid the funding fees, we've claimed the entire incoming HTLC set, which is bigger
+              // than the fees (otherwise we wouldn't have accepted the on-the-fly funding attempt), so it's fine.
+              // They cannot have used that additional fee credit yet because we only allow a single channel per user
+              // when on-the-fly funding is used, and it's not possible to request a splice while one is already in
+              // progress.
+              feeCredit = Some(nodeParams.db.liquidity.removeFeeCredit(remoteNodeId, payment.amountOut))
             case status: OnTheFlyFunding.Status.Funded =>
               log.warning("liquidity purchase was already signed for payment_hash={} (previousTxId={}, currentTxId={})", payment.paymentHash, status.txId, e.txId)
           }
@@ -637,7 +717,7 @@ class Peer(val nodeParams: NodeParams,
   }
 
   private def gotoConnected(connectionReady: PeerConnection.ConnectionReady, channels: Map[ChannelId, ActorRef]): State = {
-    require(remoteNodeId == connectionReady.remoteNodeId, s"invalid nodeid: $remoteNodeId != ${connectionReady.remoteNodeId}")
+    require(remoteNodeId == connectionReady.remoteNodeId, s"invalid nodeId: $remoteNodeId != ${connectionReady.remoteNodeId}")
     log.debug("got authenticated connection to address {}", connectionReady.address)
 
     if (connectionReady.outgoing) {
@@ -652,6 +732,16 @@ class Peer(val nodeParams: NodeParams,
     // We tell our peer what our current feerates are.
     connectionReady.peerConnection ! nodeParams.recommendedFeerates(remoteNodeId, connectionReady.localInit.features, connectionReady.remoteInit.features)
 
+    if (Features.canUseFeature(connectionReady.localInit.features, connectionReady.remoteInit.features, Features.FundingFeeCredit)) {
+      if (feeCredit.isEmpty) {
+        // We read the fee credit from the database on the first connection attempt.
+        // We keep track of the latest credit afterwards and don't need to read it from the DB at every reconnection. 
+        feeCredit = Some(nodeParams.db.liquidity.getFeeCredit(remoteNodeId))
+      }
+      log.info("reconnecting with fee credit = {}", feeCredit)
+      connectionReady.peerConnection ! CurrentFeeCredit(nodeParams.chainHash, feeCredit.getOrElse(0 msat))
+    }
+
     goto(CONNECTED) using ConnectedData(connectionReady.address, connectionReady.peerConnection, connectionReady.localInit, connectionReady.remoteInit, channels)
   }
 
@@ -685,17 +775,18 @@ class Peer(val nodeParams: NodeParams,
       case (paymentHash, pending) if paymentHashes.contains(paymentHash) =>
         pending.status match {
           case status: OnTheFlyFunding.Status.Proposed =>
+            log.info("cancelling on-the-fly funding for payment_hash={}", paymentHash)
             status.timer.cancel()
+            pending.createFailureCommands().foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
             true
+          // We keep proposals that have been added to fee credit until we reach the HTLC expiry or we restart. This
+          // guarantees that our peer cannot concurrently add to their fee credit a payment for which we've signed a
+          // funding transaction.
+          case _: OnTheFlyFunding.Status.AddedToFeeCredit => false
           case _: OnTheFlyFunding.Status.Funded => false
         }
       case _ => false
     }
-    unsigned.foreach {
-      case (paymentHash, pending) =>
-        log.info("cancelling on-the-fly funding for payment_hash={}", paymentHash)
-        pending.createFailureCommands().foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
-    }
     pendingOnTheFlyFunding = pendingOnTheFlyFunding.removedAll(unsigned.keys)
   }
 
@@ -706,12 +797,17 @@ class Peer(val nodeParams: NodeParams,
     })
   }
 
-  /** Return true if we have signed on-the-fly funding transactions and haven't settled the corresponding HTLCs yet. */
-  private def pendingSignedOnTheFlyFunding(): Boolean = {
-    pendingOnTheFlyFunding.exists {
+  /** Return true if we can forget pending on-the-fly funding transactions and stop ourselves. */
+  private def canForgetPendingOnTheFlyFunding(): Boolean = {
+    pendingOnTheFlyFunding.forall {
       case (_, pending) => pending.status match {
-        case _: OnTheFlyFunding.Status.Proposed => false
-        case _: OnTheFlyFunding.Status.Funded => true
+        case _: OnTheFlyFunding.Status.Proposed => true
+        // We don't stop ourselves if our peer has some fee credit.
+        // They will likely come back online to use that fee credit.
+        case _: OnTheFlyFunding.Status.AddedToFeeCredit => false
+        // We don't stop ourselves if we've signed an on-the-fly funding proposal but haven't settled HTLCs yet.
+        // We must watch the expiry of those HTLCs and obtain the preimage before they expire to get paid.
+        case _: OnTheFlyFunding.Status.Funded => false
       }
     }
   }
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/OnTheFlyFunding.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/OnTheFlyFunding.scala
index ce30ae6c69..0fb8d76f39 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/OnTheFlyFunding.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/OnTheFlyFunding.scala
@@ -27,7 +27,7 @@ import fr.acinq.eclair.channel._
 import fr.acinq.eclair.crypto.Sphinx
 import fr.acinq.eclair.wire.protocol.LiquidityAds.PaymentDetails
 import fr.acinq.eclair.wire.protocol._
-import fr.acinq.eclair.{Logs, MilliSatoshi, NodeParams, TimestampMilli, ToMilliSatoshiConversion}
+import fr.acinq.eclair.{Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, TimestampMilli, ToMilliSatoshiConversion}
 import scodec.bits.ByteVector
 
 import scala.concurrent.duration.FiniteDuration
@@ -45,6 +45,8 @@ object OnTheFlyFunding {
   object Status {
     /** We sent will_add_htlc, but didn't fund a transaction yet. */
     case class Proposed(timer: Cancellable) extends Status
+    /** Our peer revealed the preimage to add this payment to their fee credit for a future on-chain transaction. */
+    case class AddedToFeeCredit(preimage: ByteVector32) extends Status
     /**
      * We signed a transaction matching the on-the-fly funding proposed. We're waiting for the liquidity to be
      * available (channel ready or splice locked) to relay the HTLCs and complete the payment.
@@ -89,6 +91,7 @@ object OnTheFlyFunding {
   case class Pending(proposed: Seq[Proposal], status: Status) {
     val paymentHash = proposed.head.htlc.paymentHash
     val expiry = proposed.map(_.htlc.expiry).min
+    val amountOut = proposed.map(_.htlc.amount).sum
 
     /** Maximum fees that can be collected from this HTLC set. */
     def maxFees(htlcMinimum: MilliSatoshi): MilliSatoshi = proposed.map(_.maxFees(htlcMinimum)).sum
@@ -106,26 +109,26 @@ object OnTheFlyFunding {
     /** The incoming channel or splice cannot pay the liquidity fees: we must reject it and fail the corresponding upstream HTLCs. */
     case class Reject(cancel: CancelOnTheFlyFunding, paymentHashes: Set[ByteVector32]) extends ValidationResult
     /** We are on-the-fly funding a channel: if we received preimages, we must fulfill the corresponding upstream HTLCs. */
-    case class Accept(preimages: Set[ByteVector32]) extends ValidationResult
+    case class Accept(preimages: Set[ByteVector32], useFeeCredit_opt: Option[MilliSatoshi]) extends ValidationResult
   }
   // @formatter:on
 
   /** Validate an incoming channel that may use on-the-fly funding. */
-  def validateOpen(open: Either[OpenChannel, OpenDualFundedChannel], pendingOnTheFlyFunding: Map[ByteVector32, Pending]): ValidationResult = {
+  def validateOpen(open: Either[OpenChannel, OpenDualFundedChannel], pendingOnTheFlyFunding: Map[ByteVector32, Pending], feeCredit: MilliSatoshi): ValidationResult = {
     open match {
-      case Left(_) => ValidationResult.Accept(Set.empty)
+      case Left(_) => ValidationResult.Accept(Set.empty, None)
       case Right(open) => open.requestFunding_opt match {
-        case Some(requestFunding) => validate(open.temporaryChannelId, requestFunding, isChannelCreation = true, open.fundingFeerate, open.htlcMinimum, pendingOnTheFlyFunding)
-        case None => ValidationResult.Accept(Set.empty)
+        case Some(requestFunding) => validate(open.temporaryChannelId, requestFunding, isChannelCreation = true, open.fundingFeerate, open.htlcMinimum, pendingOnTheFlyFunding, feeCredit)
+        case None => ValidationResult.Accept(Set.empty, None)
       }
     }
   }
 
   /** Validate an incoming splice that may use on-the-fly funding. */
-  def validateSplice(splice: SpliceInit, htlcMinimum: MilliSatoshi, pendingOnTheFlyFunding: Map[ByteVector32, Pending]): ValidationResult = {
+  def validateSplice(splice: SpliceInit, htlcMinimum: MilliSatoshi, pendingOnTheFlyFunding: Map[ByteVector32, Pending], feeCredit: MilliSatoshi): ValidationResult = {
     splice.requestFunding_opt match {
-      case Some(requestFunding) => validate(splice.channelId, requestFunding, isChannelCreation = false, splice.feerate, htlcMinimum, pendingOnTheFlyFunding)
-      case None => ValidationResult.Accept(Set.empty)
+      case Some(requestFunding) => validate(splice.channelId, requestFunding, isChannelCreation = false, splice.feerate, htlcMinimum, pendingOnTheFlyFunding, feeCredit)
+      case None => ValidationResult.Accept(Set.empty, None)
     }
   }
 
@@ -134,7 +137,8 @@ object OnTheFlyFunding {
                        isChannelCreation: Boolean,
                        feerate: FeeratePerKw,
                        htlcMinimum: MilliSatoshi,
-                       pendingOnTheFlyFunding: Map[ByteVector32, Pending]): ValidationResult = {
+                       pendingOnTheFlyFunding: Map[ByteVector32, Pending],
+                       feeCredit: MilliSatoshi): ValidationResult = {
     val paymentHashes = requestFunding.paymentDetails match {
       case PaymentDetails.FromChannelBalance => Nil
       case PaymentDetails.FromChannelBalanceForFutureHtlc(paymentHashes) => paymentHashes
@@ -145,17 +149,24 @@ object OnTheFlyFunding {
     val totalPaymentAmount = pending.flatMap(_.proposed.map(_.htlc.amount)).sum
     // We will deduce fees from HTLCs: we check that the amount is large enough to cover the fees.
     val availableAmountForFees = pending.map(_.maxFees(htlcMinimum)).sum
-    val fees = requestFunding.fees(feerate, isChannelCreation)
+    val (feesOwed, useFeeCredit_opt) = if (feeCredit > 0.msat) {
+      // We prioritize using our peer's fee credit if they have some available.
+      val fees = requestFunding.fees(feerate, isChannelCreation).total.toMilliSatoshi
+      val useFeeCredit = feeCredit.min(fees)
+      (fees - useFeeCredit, Some(useFeeCredit))
+    } else {
+      (requestFunding.fees(feerate, isChannelCreation).total.toMilliSatoshi, None)
+    }
     val cancelAmountTooLow = CancelOnTheFlyFunding(channelId, paymentHashes, s"requested amount is too low to relay HTLCs: ${requestFunding.requestedAmount} < $totalPaymentAmount")
-    val cancelFeesTooLow = CancelOnTheFlyFunding(channelId, paymentHashes, s"htlc amount is too low to pay liquidity fees: $availableAmountForFees < ${fees.total}")
+    val cancelFeesTooLow = CancelOnTheFlyFunding(channelId, paymentHashes, s"htlc amount is too low to pay liquidity fees: $availableAmountForFees < $feesOwed")
     requestFunding.paymentDetails match {
-      case PaymentDetails.FromChannelBalance => ValidationResult.Accept(Set.empty)
+      case PaymentDetails.FromChannelBalance => ValidationResult.Accept(Set.empty, None)
       case _ if requestFunding.requestedAmount.toMilliSatoshi < totalPaymentAmount => ValidationResult.Reject(cancelAmountTooLow, paymentHashes.toSet)
-      case _: PaymentDetails.FromChannelBalanceForFutureHtlc => ValidationResult.Accept(Set.empty)
-      case _: PaymentDetails.FromFutureHtlc if availableAmountForFees < fees.total => ValidationResult.Reject(cancelFeesTooLow, paymentHashes.toSet)
-      case _: PaymentDetails.FromFutureHtlc => ValidationResult.Accept(Set.empty)
-      case _: PaymentDetails.FromFutureHtlcWithPreimage if availableAmountForFees < fees.total => ValidationResult.Reject(cancelFeesTooLow, paymentHashes.toSet)
-      case p: PaymentDetails.FromFutureHtlcWithPreimage => ValidationResult.Accept(p.preimages.toSet)
+      case _: PaymentDetails.FromChannelBalanceForFutureHtlc => ValidationResult.Accept(Set.empty, useFeeCredit_opt)
+      case _: PaymentDetails.FromFutureHtlc if availableAmountForFees < feesOwed => ValidationResult.Reject(cancelFeesTooLow, paymentHashes.toSet)
+      case _: PaymentDetails.FromFutureHtlc => ValidationResult.Accept(Set.empty, useFeeCredit_opt)
+      case _: PaymentDetails.FromFutureHtlcWithPreimage if availableAmountForFees < feesOwed => ValidationResult.Reject(cancelFeesTooLow, paymentHashes.toSet)
+      case p: PaymentDetails.FromFutureHtlcWithPreimage => ValidationResult.Accept(p.preimages.toSet, useFeeCredit_opt)
     }
   }
 
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala
index da492c9ca7..7d0fa016f2 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala
@@ -74,10 +74,21 @@ object ChannelTlv {
 
   val provideFundingCodec: Codec[ProvideFundingTlv] = tlvField(LiquidityAds.Codecs.willFund)
 
+  /** Fee credit that will be used for the given on-the-fly funding operation. */
+  case class FeeCreditUsedTlv(amount: MilliSatoshi) extends AcceptDualFundedChannelTlv with SpliceAckTlv
+
+  val feeCreditUsedCodec: Codec[FeeCreditUsedTlv] = tlvField(tmillisatoshi)
+
   case class PushAmountTlv(amount: MilliSatoshi) extends OpenDualFundedChannelTlv with AcceptDualFundedChannelTlv with SpliceInitTlv with SpliceAckTlv
 
   val pushAmountCodec: Codec[PushAmountTlv] = tlvField(tmillisatoshi)
 
+  /**
+   * This is an internal TLV for which we DON'T specify a codec: this isn't meant to be read or written on the wire.
+   * This is only used to decorate open_channel2 and splice_init with the [[Features.FundingFeeCredit]] available.
+   */
+  case class UseFeeCredit(amount: MilliSatoshi) extends OpenDualFundedChannelTlv with SpliceInitTlv
+
 }
 
 object OpenChannelTlv {
@@ -169,6 +180,7 @@ object SpliceAckTlv {
     .typecase(UInt64(2), requireConfirmedInputsCodec)
     // We use a temporary TLV while the spec is being reviewed.
     .typecase(UInt64(1339), provideFundingCodec)
+    .typecase(UInt64(41042), feeCreditUsedCodec)
     .typecase(UInt64(0x47000007), tlvField(tmillisatoshi.as[PushAmountTlv]))
   )
 }
@@ -187,6 +199,7 @@ object AcceptDualFundedChannelTlv {
     .typecase(UInt64(2), requireConfirmedInputsCodec)
     // We use a temporary TLV while the spec is being reviewed.
     .typecase(UInt64(1339), provideFundingCodec)
+    .typecase(UInt64(41042), feeCreditUsedCodec)
     .typecase(UInt64(0x47000007), pushAmountCodec)
   )
 
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala
index 417d7cee94..b422d8598c 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala
@@ -460,6 +460,14 @@ object LightningMessageCodecs {
       ("paymentHashes" | listOfN(uint16, bytes32)) ::
       ("reason" | varsizebinarydata)).as[CancelOnTheFlyFunding]
 
+  val addFeeCreditCodec: Codec[AddFeeCredit] = (
+    ("chainHash" | blockHash) ::
+      ("preimage" | bytes32)).as[AddFeeCredit]
+
+  val currentFeeCreditCodec: Codec[CurrentFeeCredit] = (
+    ("chainHash" | blockHash) ::
+      ("amount" | millisatoshi)).as[CurrentFeeCredit]
+
   val unknownMessageCodec: Codec[UnknownMessage] = (
     ("tag" | uint16) ::
       ("message" | bytes)
@@ -517,6 +525,10 @@ object LightningMessageCodecs {
     .typecase(41043, willFailMalformedHtlcCodec)
     .typecase(41044, cancelOnTheFlyFundingCodec)
     //
+    //
+    .typecase(41045, addFeeCreditCodec)
+    .typecase(41046, currentFeeCreditCodec)
+    //
     .typecase(37000, spliceInitCodec)
     .typecase(37002, spliceAckCodec)
     .typecase(37004, spliceLockedCodec)
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala
index 5bc5dc75b5..e045da1475 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala
@@ -254,6 +254,7 @@ case class OpenDualFundedChannel(chainHash: BlockHash,
   val channelType_opt: Option[ChannelType] = tlvStream.get[ChannelTlv.ChannelTypeTlv].map(_.channelType)
   val requireConfirmedInputs: Boolean = tlvStream.get[ChannelTlv.RequireConfirmedInputsTlv].nonEmpty
   val requestFunding_opt: Option[LiquidityAds.RequestFunding] = tlvStream.get[ChannelTlv.RequestFundingTlv].map(_.request)
+  val useFeeCredit_opt: Option[MilliSatoshi] = tlvStream.get[ChannelTlv.UseFeeCredit].map(_.amount)
   val pushAmount: MilliSatoshi = tlvStream.get[ChannelTlv.PushAmountTlv].map(_.amount).getOrElse(0 msat)
 }
 
@@ -307,6 +308,7 @@ case class SpliceInit(channelId: ByteVector32,
                       tlvStream: TlvStream[SpliceInitTlv] = TlvStream.empty) extends ChannelMessage with HasChannelId {
   val requireConfirmedInputs: Boolean = tlvStream.get[ChannelTlv.RequireConfirmedInputsTlv].nonEmpty
   val requestFunding_opt: Option[LiquidityAds.RequestFunding] = tlvStream.get[ChannelTlv.RequestFundingTlv].map(_.request)
+  val useFeeCredit_opt: Option[MilliSatoshi] = tlvStream.get[ChannelTlv.UseFeeCredit].map(_.amount)
   val pushAmount: MilliSatoshi = tlvStream.get[ChannelTlv.PushAmountTlv].map(_.amount).getOrElse(0 msat)
 }
 
@@ -331,11 +333,12 @@ case class SpliceAck(channelId: ByteVector32,
 }
 
 object SpliceAck {
-  def apply(channelId: ByteVector32, fundingContribution: Satoshi, fundingPubKey: PublicKey, pushAmount: MilliSatoshi, requireConfirmedInputs: Boolean, willFund_opt: Option[LiquidityAds.WillFund]): SpliceAck = {
+  def apply(channelId: ByteVector32, fundingContribution: Satoshi, fundingPubKey: PublicKey, pushAmount: MilliSatoshi, requireConfirmedInputs: Boolean, willFund_opt: Option[LiquidityAds.WillFund], feeCreditUsed_opt: Option[MilliSatoshi]): SpliceAck = {
     val tlvs: Set[SpliceAckTlv] = Set(
       if (pushAmount > 0.msat) Some(ChannelTlv.PushAmountTlv(pushAmount)) else None,
       if (requireConfirmedInputs) Some(ChannelTlv.RequireConfirmedInputsTlv()) else None,
-      willFund_opt.map(ChannelTlv.ProvideFundingTlv)
+      willFund_opt.map(ChannelTlv.ProvideFundingTlv),
+      feeCreditUsed_opt.map(ChannelTlv.FeeCreditUsedTlv),
     ).flatten
     SpliceAck(channelId, fundingContribution, fundingPubKey, TlvStream(tlvs))
   }
@@ -673,4 +676,14 @@ object CancelOnTheFlyFunding {
   def apply(channelId: ByteVector32, paymentHashes: List[ByteVector32], reason: String): CancelOnTheFlyFunding = CancelOnTheFlyFunding(channelId, paymentHashes, ByteVector.view(reason.getBytes(Charsets.US_ASCII)))
 }
 
+/**
+ * This message is used to reveal the preimage of a small payment for which it isn't economical to perform an on-chain
+ * transaction. The amount of the payment will be added to our fee credit, which can be used when a future on-chain
+ * transaction is needed. This message requires the [[Features.FundingFeeCredit]] feature.
+ */
+case class AddFeeCredit(chainHash: BlockHash, preimage: ByteVector32) extends HasChainHash
+
+/** This message contains our current fee credit: the liquidity provider is the source of truth for that value. */
+case class CurrentFeeCredit(chainHash: BlockHash, amount: MilliSatoshi) extends HasChainHash
+
 case class UnknownMessage(tag: Int, data: ByteVector) extends LightningMessage
\ No newline at end of file
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LiquidityAds.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LiquidityAds.scala
index 7a9e37ff15..933e3090fa 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LiquidityAds.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LiquidityAds.scala
@@ -23,7 +23,7 @@ import fr.acinq.eclair.blockchain.fee.FeeratePerKw
 import fr.acinq.eclair.channel._
 import fr.acinq.eclair.transactions.Transactions
 import fr.acinq.eclair.wire.protocol.CommonCodecs._
-import fr.acinq.eclair.wire.protocol.TlvCodecs.{genericTlv, tlvField, tsatoshi32}
+import fr.acinq.eclair.wire.protocol.TlvCodecs.tlvField
 import fr.acinq.eclair.{MilliSatoshi, ToMilliSatoshiConversion, UInt64}
 import scodec.Codec
 import scodec.bits.{BitVector, ByteVector}
@@ -124,7 +124,7 @@ object LiquidityAds {
 
   /** Sellers offer various rates and payment options. */
   case class WillFundRates(fundingRates: List[FundingRate], paymentTypes: Set[PaymentType]) {
-    def validateRequest(nodeKey: PrivateKey, channelId: ByteVector32, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, request: RequestFunding, isChannelCreation: Boolean): Either[ChannelException, WillFundPurchase] = {
+    def validateRequest(nodeKey: PrivateKey, channelId: ByteVector32, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, request: RequestFunding, isChannelCreation: Boolean, feeCreditUsed_opt: Option[MilliSatoshi]): Either[ChannelException, WillFundPurchase] = {
       if (!paymentTypes.contains(request.paymentDetails.paymentType)) {
         Left(InvalidLiquidityAdsPaymentType(channelId, request.paymentDetails.paymentType, paymentTypes))
       } else if (!fundingRates.contains(request.fundingRate)) {
@@ -133,7 +133,11 @@ object LiquidityAds {
         Left(InvalidLiquidityAdsRate(channelId))
       } else {
         val sig = Crypto.sign(request.fundingRate.signedData(fundingScript), nodeKey)
-        val purchase = Purchase.Standard(request.requestedAmount, request.fundingRate.fees(fundingFeerate, request.requestedAmount, request.requestedAmount, isChannelCreation), request.paymentDetails)
+        val fees = request.fundingRate.fees(fundingFeerate, request.requestedAmount, request.requestedAmount, isChannelCreation)
+        val purchase = feeCreditUsed_opt match {
+          case Some(feeCreditUsed) => Purchase.WithFeeCredit(request.requestedAmount, fees, feeCreditUsed, request.paymentDetails)
+          case None => Purchase.Standard(request.requestedAmount, fees, request.paymentDetails)
+        }
         Right(WillFundPurchase(WillFund(request.fundingRate, fundingScript, sig), purchase))
       }
     }
@@ -141,9 +145,9 @@ object LiquidityAds {
     def findRate(requestedAmount: Satoshi): Option[FundingRate] = fundingRates.find(r => r.minAmount <= requestedAmount && requestedAmount <= r.maxAmount)
   }
 
-  def validateRequest(nodeKey: PrivateKey, channelId: ByteVector32, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, isChannelCreation: Boolean, request_opt: Option[RequestFunding], rates_opt: Option[WillFundRates]): Either[ChannelException, Option[WillFundPurchase]] = {
+  def validateRequest(nodeKey: PrivateKey, channelId: ByteVector32, fundingScript: ByteVector, fundingFeerate: FeeratePerKw, isChannelCreation: Boolean, request_opt: Option[RequestFunding], rates_opt: Option[WillFundRates], feeCreditUsed_opt: Option[MilliSatoshi]): Either[ChannelException, Option[WillFundPurchase]] = {
     (request_opt, rates_opt) match {
-      case (Some(request), Some(rates)) => rates.validateRequest(nodeKey, channelId, fundingScript, fundingFeerate, request, isChannelCreation).map(l => Some(l))
+      case (Some(request), Some(rates)) => rates.validateRequest(nodeKey, channelId, fundingScript, fundingFeerate, request, isChannelCreation, feeCreditUsed_opt).map(l => Some(l))
       case _ => Right(None)
     }
   }
@@ -225,7 +229,11 @@ object LiquidityAds {
   }
 
   object Purchase {
+    // @formatter:off
     case class Standard(amount: Satoshi, fees: Fees, paymentDetails: PaymentDetails) extends Purchase()
+    /** The liquidity purchase was paid (partially or entirely) using [[fr.acinq.eclair.Features.FundingFeeCredit]]. */
+    case class WithFeeCredit(amount: Satoshi, fees: Fees, feeCreditUsed: MilliSatoshi, paymentDetails: PaymentDetails) extends Purchase()
+    // @formatter:on
   }
 
   case class WillFundPurchase(willFund: WillFund, purchase: Purchase)
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala
index b48c937552..6f56850eb0 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala
@@ -246,6 +246,7 @@ object TestConstants {
       None,
       None,
       isChannelOpener = true,
+      paysCommitTxFees = true,
       dualFunded = false,
       fundingSatoshis,
       unlimitedMaxHtlcValueInFlight = false,
@@ -419,6 +420,7 @@ object TestConstants {
       None,
       None,
       isChannelOpener = false,
+      paysCommitTxFees = false,
       dualFunded = false,
       fundingSatoshis,
       unlimitedMaxHtlcValueInFlight = false,
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala
index d82768400c..ec58b9cc3c 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala
@@ -214,8 +214,8 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
   private def createFixtureParams(fundingAmountA: Satoshi, fundingAmountB: Satoshi, targetFeerate: FeeratePerKw, dustLimit: Satoshi, lockTime: Long, requireConfirmedInputs: RequireConfirmedInputs = RequireConfirmedInputs(forLocal = false, forRemote = false), nonInitiatorPaysCommitTxFees: Boolean = false): FixtureParams = {
     val channelFeatures = ChannelFeatures(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), Features[InitFeature](Features.DualFunding -> FeatureSupport.Optional), Features[InitFeature](Features.DualFunding -> FeatureSupport.Optional), announceChannel = true)
     val Seq(nodeParamsA, nodeParamsB) = Seq(TestConstants.Alice.nodeParams, TestConstants.Bob.nodeParams).map(_.copy(features = Features(channelFeatures.features.map(f => f -> FeatureSupport.Optional).toMap[Feature, FeatureSupport])))
-    val localParamsA = makeChannelParams(nodeParamsA, nodeParamsA.features.initFeatures(), None, None, isChannelOpener = true, dualFunded = true, fundingAmountA, unlimitedMaxHtlcValueInFlight = false).copy(paysCommitTxFees = !nonInitiatorPaysCommitTxFees)
-    val localParamsB = makeChannelParams(nodeParamsB, nodeParamsB.features.initFeatures(), None, None, isChannelOpener = false, dualFunded = true, fundingAmountB, unlimitedMaxHtlcValueInFlight = false).copy(paysCommitTxFees = nonInitiatorPaysCommitTxFees)
+    val localParamsA = makeChannelParams(nodeParamsA, nodeParamsA.features.initFeatures(), None, None, isChannelOpener = true, paysCommitTxFees = !nonInitiatorPaysCommitTxFees, dualFunded = true, fundingAmountA, unlimitedMaxHtlcValueInFlight = false)
+    val localParamsB = makeChannelParams(nodeParamsB, nodeParamsB.features.initFeatures(), None, None, isChannelOpener = false, paysCommitTxFees = nonInitiatorPaysCommitTxFees, dualFunded = true, fundingAmountB, unlimitedMaxHtlcValueInFlight = false)
 
     val Seq(remoteParamsA, remoteParamsB) = Seq((nodeParamsA, localParamsA), (nodeParamsB, localParamsB)).map {
       case (nodeParams, localParams) =>
@@ -617,6 +617,91 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
     }
   }
 
+  test("initiator does not contribute -- on-the-fly funding with fee credit") {
+    val targetFeerate = FeeratePerKw(5000 sat)
+    val fundingA = 2_500.sat
+    val utxosA = Seq(5_000 sat)
+    val fundingB = 150_000.sat
+    val utxosB = Seq(200_000 sat)
+    // The initiator contributes a small amount, and pays the remaining liquidity fees from its fee credit.
+    val purchase = LiquidityAds.Purchase.WithFeeCredit(fundingB, LiquidityAds.Fees(2500 sat, 7500 sat), 7_500_000 msat, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(Nil))
+    withFixture(fundingA, utxosA, fundingB, utxosB, targetFeerate, 330 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false), Some(purchase)) { f =>
+      import f._
+
+      // Alice has enough fee credit.
+      fixtureParams.nodeParamsB.db.liquidity.addFeeCredit(fixtureParams.nodeParamsA.nodeId, 7_500_000 msat)
+
+      alice ! Start(alice2bob.ref)
+      bob ! Start(bob2alice.ref)
+
+      // Alice --- tx_add_input --> Bob
+      fwd.forwardAlice2Bob[TxAddInput]
+      // Alice <-- tx_add_input --- Bob
+      fwd.forwardBob2Alice[TxAddInput]
+      // Alice --- tx_add_output --> Bob
+      fwd.forwardAlice2Bob[TxAddOutput]
+      // Alice <-- tx_add_output --- Bob
+      fwd.forwardBob2Alice[TxAddOutput]
+      // Alice --- tx_complete --> Bob
+      fwd.forwardAlice2Bob[TxComplete]
+      // Alice <-- tx_complete --- Bob
+      fwd.forwardBob2Alice[TxComplete]
+
+      // Alice sends signatures first as she contributed less.
+      val successA = alice2bob.expectMsgType[Succeeded]
+      val successB = bob2alice.expectMsgType[Succeeded]
+      val (txA, _, txB, commitmentB) = fixtureParams.exchangeSigsAliceFirst(aliceParams, successA, successB)
+      // Alice partially paid fees to Bob during the interactive-tx using her channel balance, the rest was paid from fee credit.
+      assert(commitmentB.localCommit.spec.toLocal == (fundingA + fundingB).toMilliSatoshi)
+      assert(commitmentB.localCommit.spec.toRemote == 0.msat)
+
+      // The resulting transaction is valid.
+      assert(txA.txId == txB.txId)
+      assert(txA.tx.localFees == 2_500_000.msat)
+      assert(txB.tx.remoteFees == 2_500_000.msat)
+      assert(txB.tx.localFees > 0.msat)
+      val probe = TestProbe()
+      walletA.publishTransaction(txA.signedTx).pipeTo(probe.ref)
+      probe.expectMsg(txA.txId)
+      walletA.getMempoolTx(txA.txId).pipeTo(probe.ref)
+      val mempoolTx = probe.expectMsgType[MempoolTx]
+      assert(mempoolTx.fees == txA.tx.fees)
+      assert(targetFeerate * 0.9 <= txA.feerate && txA.feerate < targetFeerate * 1.25, s"unexpected feerate (target=$targetFeerate actual=${txA.feerate})")
+    }
+  }
+
+  test("initiator does not contribute -- on-the-fly funding without enough fee credit") {
+    val targetFeerate = FeeratePerKw(5000 sat)
+    val fundingB = 150_000.sat
+    val utxosB = Seq(200_000 sat)
+    // The initiator wants to pay the liquidity fees from their fee credit, but they don't have enough of it.
+    val purchase = LiquidityAds.Purchase.WithFeeCredit(fundingB, LiquidityAds.Fees(2500 sat, 7500 sat), 10_000_000 msat, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(Nil))
+    withFixture(0 sat, Nil, fundingB, utxosB, targetFeerate, 330 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false), Some(purchase)) { f =>
+      import f._
+
+      // Alice doesn't have enough fee credit.
+      fixtureParams.nodeParamsB.db.liquidity.addFeeCredit(fixtureParams.nodeParamsA.nodeId, 9_000_000 msat)
+
+      alice ! Start(alice2bob.ref)
+      bob ! Start(bob2alice.ref)
+
+      // Alice --- tx_add_output --> Bob
+      fwd.forwardAlice2Bob[TxAddOutput]
+      // Alice <-- tx_add_input --- Bob
+      fwd.forwardBob2Alice[TxAddInput]
+      // Alice --- tx_complete --> Bob
+      fwd.forwardAlice2Bob[TxComplete]
+      // Alice <-- tx_add_output --- Bob
+      fwd.forwardBob2Alice[TxAddOutput]
+      // Alice --- tx_complete --> Bob
+      fwd.forwardAlice2Bob[TxComplete]
+      // Alice <-- tx_complete --- Bob
+      fwd.forwardBob2Alice[TxComplete]
+      // Bob rejects the funding attempt because Alice doesn't have enough fee credit.
+      assert(bob2alice.expectMsgType[RemoteFailure].cause.isInstanceOf[InvalidCompleteInteractiveTx])
+    }
+  }
+
   test("initiator and non-initiator splice-in") {
     val targetFeerate = FeeratePerKw(1000 sat)
     // We chose those amounts to ensure that Bob always signs first:
@@ -2254,6 +2339,10 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
     val bobSplice = params.spawnTxBuilderSpliceBob(spliceParams, previousCommitment, wallet, Some(purchase))
     bobSplice ! Start(probe.ref)
     assert(probe.expectMsgType[LocalFailure].cause == InvalidFundingBalances(params.channelId, 620_000 sat, 625_000_000 msat, -5_000_000 msat))
+    // If Alice is using fee credit to pay the liquidity fees, the funding attempt is valid.
+    val bobFeeCredit = params.spawnTxBuilderBob(wallet, params.fundingParamsB, Some(LiquidityAds.Purchase.WithFeeCredit(500_000 sat, LiquidityAds.Fees(5000 sat, 20_000 sat), 25_000_000 msat, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(Nil))))
+    bobFeeCredit ! Start(probe.ref)
+    probe.expectNoMessage(100 millis)
     // If we use a payment type where fees are paid outside of the interactive-tx session, the funding attempt is valid.
     val bobFutureHtlc = params.spawnTxBuilderBob(wallet, params.fundingParamsB, Some(purchase.copy(paymentDetails = LiquidityAds.PaymentDetails.FromFutureHtlc(Nil))))
     bobFutureHtlc ! Start(probe.ref)
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala
index db65e9607a..246a6fc34c 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala
@@ -108,6 +108,19 @@ class WaitForOpenDualFundedChannelStateSpec extends TestKitBaseClass with Fixtur
     assert(accept.willFund_opt.nonEmpty)
   }
 
+  test("recv OpenDualFundedChannel (with liquidity ads and fee credit)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.LiquidityAds), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f =>
+    import f._
+
+    val open = alice2bob.expectMsgType[OpenDualFundedChannel]
+    val requestFunds = LiquidityAds.RequestFunding(TestConstants.nonInitiatorFundingSatoshis, TestConstants.defaultLiquidityRates.fundingRates.head, LiquidityAds.PaymentDetails.FromChannelBalance)
+    val openWithFundsRequest = open.copy(tlvStream = open.tlvStream.copy(records = open.tlvStream.records + ChannelTlv.RequestFundingTlv(requestFunds) + ChannelTlv.UseFeeCredit(2_500_000 msat)))
+    alice2bob.forward(bob, openWithFundsRequest)
+    val accept = bob2alice.expectMsgType[AcceptDualFundedChannel]
+    assert(accept.fundingAmount == TestConstants.nonInitiatorFundingSatoshis)
+    assert(accept.willFund_opt.nonEmpty)
+    assert(accept.tlvStream.get[ChannelTlv.FeeCreditUsedTlv].map(_.amount).contains(2_500_000 msat))
+  }
+
   test("recv OpenDualFundedChannel (with push amount)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f =>
     import f._
 
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/db/LiquidityDbSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/db/LiquidityDbSpec.scala
index 978236a911..acd86a9f28 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/db/LiquidityDbSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/db/LiquidityDbSpec.scala
@@ -185,4 +185,27 @@ class LiquidityDbSpec extends AnyFunSuite {
     }
   }
 
+  test("add/get/remove fee credit") {
+    forAllDbs { dbs =>
+      val db = dbs.liquidity
+      val nodeId = randomKey().publicKey
+
+      // Initially, the DB is empty.
+      assert(db.getFeeCredit(nodeId) == 0.msat)
+      assert(db.removeFeeCredit(nodeId, 0 msat) == 0.msat)
+
+      // We owe some fee credit to our peer.
+      assert(db.addFeeCredit(nodeId, 211_754 msat, receivedAt = TimestampMilli(50_000)) == 211_754.msat)
+      assert(db.getFeeCredit(nodeId) == 211_754.msat)
+      assert(db.addFeeCredit(nodeId, 245 msat, receivedAt = TimestampMilli(55_000)) == 211_999.msat)
+      assert(db.getFeeCredit(nodeId) == 211_999.msat)
+
+      // We consume some of the fee credit.
+      assert(db.removeFeeCredit(nodeId, 11_999 msat) == 200_000.msat)
+      assert(db.getFeeCredit(nodeId) == 200_000.msat)
+      assert(db.removeFeeCredit(nodeId, 250_000 msat) == 0.msat)
+      assert(db.getFeeCredit(nodeId) == 0.msat)
+    }
+  }
+
 }
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/OnTheFlyFundingSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/OnTheFlyFundingSpec.scala
index ef58e5b332..90cca0c64a 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/OnTheFlyFundingSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/OnTheFlyFundingSpec.scala
@@ -32,8 +32,8 @@ import fr.acinq.eclair.io.{Peer, PeerConnection, PendingChannelsRateLimiter}
 import fr.acinq.eclair.wire.protocol
 import fr.acinq.eclair.wire.protocol._
 import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, FeatureSupport, Features, MilliSatoshi, MilliSatoshiLong, NodeParams, TestConstants, TestKitBaseClass, TimestampMilli, ToMilliSatoshiConversion, UInt64, randomBytes, randomBytes32, randomKey, randomLong}
-import org.scalatest.Outcome
 import org.scalatest.funsuite.FixtureAnyFunSuiteLike
+import org.scalatest.{Outcome, Tag}
 
 import java.util.UUID
 import scala.concurrent.duration.DurationInt
@@ -42,6 +42,8 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
 
   import OnTheFlyFundingSpec._
 
+  val withFeeCredit = "with_fee_credit"
+
   val remoteFeatures = Features(
     Features.StaticRemoteKey -> FeatureSupport.Optional,
     Features.AnchorOutputsZeroFeeHtlcTx -> FeatureSupport.Optional,
@@ -50,6 +52,13 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
     Features.OnTheFlyFunding -> FeatureSupport.Optional,
   )
 
+  val remoteFeaturesWithFeeCredit = Features(
+    Features.DualFunding -> FeatureSupport.Optional,
+    Features.SplicePrototype -> FeatureSupport.Optional,
+    Features.OnTheFlyFunding -> FeatureSupport.Optional,
+    Features.FundingFeeCredit -> FeatureSupport.Optional,
+  )
+
   case class FixtureParam(nodeParams: NodeParams, remoteNodeId: PublicKey, peer: TestFSMRef[Peer.State, Peer.Data, Peer], peerConnection: TestProbe, channel: TestProbe, register: TestProbe, rateLimiter: TestProbe, probe: TestProbe) {
     def connect(peer: TestFSMRef[Peer.State, Peer.Data, Peer], remoteInit: protocol.Init = protocol.Init(remoteFeatures.initFeatures()), channelCount: Int = 0): Unit = {
       val localInit = protocol.Init(nodeParams.features.initFeatures())
@@ -110,13 +119,40 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
                               channelId: ByteVector32 = randomBytes32(),
                               fees: LiquidityAds.Fees = LiquidityAds.Fees(0 sat, 0 sat),
                               fundingTxIndex: Long = 0,
-                              htlcMinimum: MilliSatoshi = 1 msat): LiquidityPurchaseSigned = {
-      val purchase = LiquidityAds.Purchase.Standard(amount, fees, paymentDetails)
+                              htlcMinimum: MilliSatoshi = 1 msat,
+                              feeCreditUsed_opt: Option[MilliSatoshi] = None): LiquidityPurchaseSigned = {
+      val purchase = feeCreditUsed_opt match {
+        case Some(feeCredit) => LiquidityAds.Purchase.WithFeeCredit(amount, fees, feeCredit, paymentDetails)
+        case None => LiquidityAds.Purchase.Standard(amount, fees, paymentDetails)
+      }
       val event = LiquidityPurchaseSigned(channelId, TxId(randomBytes32()), fundingTxIndex, htlcMinimum, purchase)
       peer ! event
       event
     }
 
+    def verifyFulfilledUpstream(upstream: Upstream.Hot, preimage: ByteVector32): Unit = {
+      val incomingHtlcs = upstream match {
+        case u: Upstream.Hot.Channel => Seq(u.add)
+        case u: Upstream.Hot.Trampoline => u.received.map(_.add)
+        case _: Upstream.Local => Nil
+      }
+      val fulfilled = incomingHtlcs.map(_ => register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]])
+      assert(fulfilled.map(_.channelId).toSet == incomingHtlcs.map(_.channelId).toSet)
+      assert(fulfilled.map(_.message.id).toSet == incomingHtlcs.map(_.id).toSet)
+      assert(fulfilled.map(_.message.r).toSet == Set(preimage))
+    }
+
+    def verifyFailedUpstream(upstream: Upstream.Hot): Unit = {
+      val incomingHtlcs = upstream match {
+        case u: Upstream.Hot.Channel => Seq(u.add)
+        case u: Upstream.Hot.Trampoline => u.received.map(_.add)
+        case _: Upstream.Local => Nil
+      }
+      val failed = incomingHtlcs.map(_ => register.expectMsgType[Register.Forward[CMD_FAIL_HTLC]])
+      assert(failed.map(_.channelId).toSet == incomingHtlcs.map(_.channelId).toSet)
+      assert(failed.map(_.message.id).toSet == incomingHtlcs.map(_.id).toSet)
+    }
+
     def makeChannelData(htlcMinimum: MilliSatoshi = 1 msat, localChanges: LocalChanges = LocalChanges(Nil, Nil, Nil)): DATA_NORMAL = {
       val commitments = CommitmentsSpec.makeCommitments(500_000_000 msat, 500_000_000 msat, nodeParams.nodeId, remoteNodeId, announceChannel = false)
         .modify(_.params.remoteParams.htlcMinimum).setTo(htlcMinimum)
@@ -138,6 +174,7 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
       .modify(_.features.activated).using(_ + (Features.DualFunding -> FeatureSupport.Optional))
       .modify(_.features.activated).using(_ + (Features.SplicePrototype -> FeatureSupport.Optional))
       .modify(_.features.activated).using(_ + (Features.OnTheFlyFunding -> FeatureSupport.Optional))
+      .modify(_.features.activated).usingIf(test.tags.contains(withFeeCredit))(_ + (Features.FundingFeeCredit -> FeatureSupport.Optional))
     val remoteNodeId = randomKey().publicKey
     val register = TestProbe()
     val channel = TestProbe()
@@ -228,6 +265,25 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
     })
   }
 
+  test("ignore remote failure after adding to fee credit", Tag(withFeeCredit)) { f =>
+    import f._
+
+    connect(peer)
+
+    val upstream = upstreamChannel(1_500 msat, expiryIn, paymentHash)
+    val willAdd = proposeFunding(1_000 msat, expiryOut, paymentHash, upstream)
+    peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage))
+    assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 1_000.msat)
+    verifyFulfilledUpstream(upstream, preimage)
+
+    peerConnection.send(peer, WillFailHtlc(willAdd.id, paymentHash, randomBytes(25)))
+    peerConnection.expectMsgType[Warning]
+    peerConnection.send(peer, WillFailMalformedHtlc(willAdd.id, paymentHash, randomBytes32(), InvalidOnionHmac(randomBytes32()).code))
+    peerConnection.expectMsgType[Warning]
+    peerConnection.expectNoMessage(100 millis)
+    register.expectNoMessage(100 millis)
+  }
+
   test("proposed on-the-fly funding timeout") { f =>
     import f._
 
@@ -285,6 +341,22 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
     peerConnection.expectNoMessage(100 millis)
   }
 
+  test("proposed on-the-fly funding timeout (fee credit)", Tag(withFeeCredit)) { f =>
+    import f._
+
+    connect(peer)
+
+    val upstream = upstreamChannel(10_000_000 msat, CltvExpiry(550), paymentHash)
+    proposeFunding(10_000_000 msat, CltvExpiry(500), paymentHash, upstream)
+    peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage))
+    assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 10_000_000.msat)
+    verifyFulfilledUpstream(upstream, preimage)
+
+    peer ! OnTheFlyFundingTimeout(paymentHash)
+    register.expectNoMessage(100 millis)
+    peerConnection.expectNoMessage(100 millis)
+  }
+
   test("proposed on-the-fly funding HTLC timeout") { f =>
     import f._
 
@@ -336,6 +408,22 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
     awaitCond(nodeParams.db.liquidity.listPendingOnTheFlyFunding(remoteNodeId).isEmpty, interval = 100 millis)
   }
 
+  test("proposed on-the-fly funding HTLC timeout (fee credit)", Tag(withFeeCredit)) { f =>
+    import f._
+
+    connect(peer)
+
+    val upstream = upstreamChannel(500 msat, CltvExpiry(550), paymentHash)
+    proposeFunding(500 msat, CltvExpiry(500), paymentHash, upstream)
+    peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage))
+    assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 500.msat)
+    verifyFulfilledUpstream(upstream, preimage)
+
+    peer ! CurrentBlockHeight(BlockHeight(560))
+    register.expectNoMessage(100 millis)
+    peerConnection.expectNoMessage(100 millis)
+  }
+
   test("signed on-the-fly funding HTLC timeout after disconnection") { f =>
     import f._
 
@@ -379,6 +467,74 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
     probe.expectTerminated(peerAfterRestart.ref)
   }
 
+  test("add proposal to fee credit", Tag(withFeeCredit)) { f =>
+    import f._
+
+    val remoteInit = protocol.Init(remoteFeaturesWithFeeCredit.initFeatures())
+    connect(peer, remoteInit)
+    assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 0.msat)
+
+    val upstream1 = upstreamChannel(10_000_000 msat, expiryIn, paymentHash)
+    proposeFunding(10_000_000 msat, expiryOut, paymentHash, upstream1)
+    val upstream2 = upstreamChannel(5_000_000 msat, expiryIn, paymentHash)
+    proposeFunding(5_000_000 msat, expiryOut, paymentHash, upstream2)
+
+    // Both HTLCs are automatically added to fee credit.
+    peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage))
+    assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 15_000_000.msat)
+    verifyFulfilledUpstream(Upstream.Hot.Trampoline(upstream1 :: upstream2 :: Nil), preimage)
+
+    // Another unrelated payment is added to fee credit.
+    val preimage3 = randomBytes32()
+    val paymentHash3 = Crypto.sha256(preimage3)
+    val upstream3 = upstreamChannel(2_500_000 msat, expiryIn, paymentHash3)
+    proposeFunding(2_000_000 msat, expiryOut, paymentHash3, upstream3)
+    peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage3))
+    assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 17_000_000.msat)
+    verifyFulfilledUpstream(upstream3, preimage3)
+
+    // Another payment for the same payment_hash is added to fee credit.
+    val upstream4 = upstreamChannel(5_000_000 msat, expiryIn, paymentHash)
+    proposeExtraFunding(3_000_000 msat, expiryOut, paymentHash, upstream4)
+    verifyFulfilledUpstream(upstream4, preimage)
+
+    // We don't fail proposals added to fee credit on disconnection.
+    disconnect()
+    connect(peer, remoteInit)
+    assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 17_000_000.msat)
+
+    // Duplicate or unknown add_fee_credit are ignored.
+    peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, randomBytes32()))
+    peerConnection.expectMsgType[Warning]
+    assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 17_000_000.msat)
+    peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage))
+    peerConnection.expectMsgType[Warning]
+    peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage3))
+    peerConnection.expectMsgType[Warning]
+    register.expectNoMessage(100 millis)
+    peerConnection.expectNoMessage(100 millis)
+  }
+
+  test("add proposal to fee credit after signing transaction", Tag(withFeeCredit)) { f =>
+    import f._
+
+    connect(peer)
+
+    val upstream = upstreamChannel(25_000_000 msat, expiryIn, paymentHash)
+    proposeFunding(25_000_000 msat, expiryOut, paymentHash, upstream)
+    signLiquidityPurchase(25_000 sat, LiquidityAds.PaymentDetails.FromFutureHtlc(paymentHash :: Nil))
+
+    // The proposal was signed, it cannot also be added to fee credit.
+    peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage))
+    peerConnection.expectMsgType[Warning]
+    verifyFulfilledUpstream(upstream, preimage)
+
+    // We don't added the payment amount to fee credit.
+    disconnect()
+    connect(peer, protocol.Init(remoteFeaturesWithFeeCredit.initFeatures()))
+    assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 0.msat)
+  }
+
   test("receive open_channel2") { f =>
     import f._
 
@@ -401,10 +557,63 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
     assert(init.fundingContribution_opt.contains(LiquidityAds.AddFunding(requestFunding.requestedAmount, nodeParams.willFundRates_opt)))
 
     // The preimage was provided, so we fulfill upstream HTLCs.
-    val fwd = register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]]
-    assert(fwd.channelId == upstream.add.channelId)
-    assert(fwd.message.id == upstream.add.id)
-    assert(fwd.message.r == preimage)
+    verifyFulfilledUpstream(upstream, preimage)
+  }
+
+  test("receive open_channel2 (fee credit)", Tag(withFeeCredit)) { f =>
+    import f._
+
+    connect(peer)
+
+    val requestFunding = LiquidityAds.RequestFunding(
+      500_000 sat,
+      LiquidityAds.FundingRate(10_000 sat, 1_000_000 sat, 0, 100, 0 sat, 0 sat),
+      LiquidityAds.PaymentDetails.FromFutureHtlc(paymentHash :: Nil)
+    )
+
+    // We don't have any fee credit yet to open a channel and the HTLC amount is too low to cover liquidity fees.
+    val upstream1 = upstreamChannel(500_000 msat, expiryIn, paymentHash)
+    proposeFunding(500_000 msat, expiryOut, paymentHash, upstream1)
+    val open1 = createOpenChannelMessage(requestFunding)
+    peerConnection.send(peer, open1)
+    rateLimiter.expectMsgType[AddOrRejectChannel].replyTo ! PendingChannelsRateLimiter.AcceptOpenChannel
+    peerConnection.expectMsgType[CancelOnTheFlyFunding]
+    verifyFailedUpstream(upstream1)
+
+    // We add some fee credit, but  not enough to cover liquidity fees.
+    val preimage2 = randomBytes32()
+    val paymentHash2 = Crypto.sha256(preimage2)
+    val upstream2 = upstreamChannel(3_000_000 msat, expiryIn, paymentHash2)
+    proposeFunding(3_000_000 msat, expiryOut, paymentHash2, upstream2)
+    peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage2))
+    assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 3_000_000.msat)
+    verifyFulfilledUpstream(upstream2, preimage2)
+
+    // We have some fee credit but it's not enough, even with HTLCs, to cover liquidity fees.
+    val upstream3 = upstreamChannel(2_000_000 msat, expiryIn, paymentHash)
+    proposeFunding(1_999_999 msat, expiryOut, paymentHash, upstream3)
+    val open2 = createOpenChannelMessage(requestFunding)
+    peerConnection.send(peer, open2)
+    rateLimiter.expectMsgType[AddOrRejectChannel].replyTo ! PendingChannelsRateLimiter.AcceptOpenChannel
+    peerConnection.expectMsgType[CancelOnTheFlyFunding]
+    verifyFailedUpstream(upstream3)
+
+    // We have some fee credit which can pay the liquidity fees when combined with HTLCs.
+    val upstream4 = upstreamChannel(4_000_000 msat, expiryIn, paymentHash)
+    proposeFunding(4_000_000 msat, expiryOut, paymentHash, upstream4)
+    val open3 = createOpenChannelMessage(requestFunding)
+    peerConnection.send(peer, open3)
+    rateLimiter.expectMsgType[AddOrRejectChannel].replyTo ! PendingChannelsRateLimiter.AcceptOpenChannel
+    val init = channel.expectMsgType[INPUT_INIT_CHANNEL_NON_INITIATOR]
+    assert(!init.localParams.isChannelOpener)
+    assert(init.localParams.paysCommitTxFees)
+    assert(init.fundingContribution_opt.contains(LiquidityAds.AddFunding(requestFunding.requestedAmount, nodeParams.willFundRates_opt)))
+    assert(channel.expectMsgType[OpenDualFundedChannel].useFeeCredit_opt.contains(3_000_000 msat))
+
+    // Once the funding transaction is signed, we remove the fee credit consumed.
+    signLiquidityPurchase(requestFunding.requestedAmount, requestFunding.paymentDetails, feeCreditUsed_opt = Some(3_000_000 msat))
+    assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 0.msat)
+    awaitCond(nodeParams.db.liquidity.getFeeCredit(remoteNodeId) == 0.msat, interval = 100 millis)
   }
 
   test("receive splice_init") { f =>
@@ -427,10 +636,41 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
     channel.expectNoMessage(100 millis)
 
     // The preimage was provided, so we fulfill upstream HTLCs.
-    val fwd = register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]]
-    assert(fwd.channelId == upstream.add.channelId)
-    assert(fwd.message.id == upstream.add.id)
-    assert(fwd.message.r == preimage)
+    verifyFulfilledUpstream(upstream, preimage)
+  }
+
+  test("receive splice_init (fee credit)", Tag(withFeeCredit)) { f =>
+    import f._
+
+    connect(peer)
+    val channelId = openChannel(200_000 sat)
+
+    // We add some fee credit to cover liquidity fees.
+    val preimage1 = randomBytes32()
+    val paymentHash1 = Crypto.sha256(preimage1)
+    val upstream1 = upstreamChannel(8_000_000 msat, expiryIn, paymentHash1)
+    proposeFunding(7_500_000 msat, expiryOut, paymentHash1, upstream1)
+    peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage1))
+    assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 7_500_000.msat)
+    verifyFulfilledUpstream(upstream1, preimage1)
+
+    // We consume that fee credit when splicing.
+    val upstream2 = upstreamChannel(1_000_000 msat, expiryIn, paymentHash)
+    proposeFunding(1_000_000 msat, expiryOut, paymentHash, upstream2)
+    val requestFunding = LiquidityAds.RequestFunding(
+      500_000 sat,
+      LiquidityAds.FundingRate(10_000 sat, 1_000_000 sat, 0, 100, 0 sat, 0 sat),
+      LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(paymentHash :: Nil)
+    )
+    val splice = createSpliceMessage(channelId, requestFunding)
+    peerConnection.send(peer, splice)
+    assert(channel.expectMsgType[SpliceInit].useFeeCredit_opt.contains(5_000_000 msat))
+    channel.expectNoMessage(100 millis)
+
+    // Once the splice transaction is signed, we remove the fee credit consumed.
+    signLiquidityPurchase(requestFunding.requestedAmount, requestFunding.paymentDetails, feeCreditUsed_opt = Some(5_000_000 msat))
+    assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 2_500_000.msat)
+    awaitCond(nodeParams.db.liquidity.getFeeCredit(remoteNodeId) == 2_500_000.msat, interval = 100 millis)
   }
 
   test("reject invalid open_channel2") { f =>
@@ -581,15 +821,9 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
     val (add1, add2) = if (cmd1.paymentHash == paymentHash1) (cmd1, cmd2) else (cmd2, cmd1)
     val outgoing = Seq(add1, add2).map(add => UpdateAddHtlc(purchase.channelId, randomHtlcId(), add.amount, add.paymentHash, add.cltvExpiry, add.onion, add.nextBlindingKey_opt, add.confidence, add.fundingFee_opt))
     add1.replyTo ! RES_ADD_SETTLED(add1.origin, outgoing.head, HtlcResult.RemoteFulfill(UpdateFulfillHtlc(purchase.channelId, outgoing.head.id, preimage1)))
-    val fwd1 = register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]]
-    assert(fwd1.channelId == upstream1.add.channelId)
-    assert(fwd1.message.id == upstream1.add.id)
-    assert(fwd1.message.r == preimage1)
+    verifyFulfilledUpstream(upstream1, preimage1)
     add2.replyTo ! RES_ADD_SETTLED(add2.origin, outgoing.last, HtlcResult.OnChainFulfill(preimage2))
-    val fwd2 = register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]]
-    assert(fwd2.channelId == upstream2.add.channelId)
-    assert(fwd2.message.id == upstream2.add.id)
-    assert(fwd2.message.r == preimage2)
+    verifyFulfilledUpstream(upstream2, preimage2)
     awaitCond(nodeParams.db.liquidity.listPendingOnTheFlyFunding(remoteNodeId).isEmpty, interval = 100 millis)
     register.expectNoMessage(100 millis)
   }
@@ -732,12 +966,93 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
 
     // The payment is fulfilled by our peer.
     cmd2.replyTo ! RES_ADD_SETTLED(cmd2.origin, htlc, HtlcResult.OnChainFulfill(preimage))
-    assert(register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]].channelId == upstream.add.channelId)
+    verifyFulfilledUpstream(upstream, preimage)
     nodeParams.db.liquidity.addOnTheFlyFundingPreimage(preimage)
     register.expectNoMessage(100 millis)
     awaitCond(nodeParams.db.liquidity.listPendingOnTheFlyFunding(remoteNodeId).isEmpty, interval = 100 millis)
   }
 
+  test("successfully relay HTLCs to on-the-fly funded channel (fee credit)", Tag(withFeeCredit)) { f =>
+    import f._
+
+    connect(peer)
+
+    // A first payment adds some fee credit.
+    val preimage1 = randomBytes32()
+    val paymentHash1 = Crypto.sha256(preimage1)
+    val upstream1 = upstreamChannel(5_000_000 msat, expiryIn, paymentHash1)
+    proposeFunding(4_000_000 msat, expiryOut, paymentHash1, upstream1)
+    peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage1))
+    assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 4_000_000.msat)
+    verifyFulfilledUpstream(upstream1, preimage1)
+
+    // A second payment will pay the rest of the liquidity fees.
+    val preimage2 = randomBytes32()
+    val paymentHash2 = Crypto.sha256(preimage2)
+    val upstream2 = upstreamChannel(16_000_000 msat, expiryIn, paymentHash2)
+    proposeFunding(15_000_000 msat, expiryOut, paymentHash2, upstream2)
+    val fees = LiquidityAds.Fees(5_000 sat, 4_000 sat)
+    val purchase = signLiquidityPurchase(200_000 sat, LiquidityAds.PaymentDetails.FromFutureHtlc(paymentHash2 :: Nil), fees = fees, feeCreditUsed_opt = Some(4_000_000 msat))
+
+    // Once the channel is ready to relay payments, we forward the remaining HTLC.
+    // We collect the liquidity fees that weren't paid by the fee credit.
+    val channelData = makeChannelData()
+    peer ! ChannelReadyForPayments(channel.ref, remoteNodeId, purchase.channelId, fundingTxIndex = 0)
+    channel.expectMsgType[CMD_GET_CHANNEL_INFO].replyTo ! RES_GET_CHANNEL_INFO(remoteNodeId, purchase.channelId, channel.ref, NORMAL, channelData)
+    val cmd = channel.expectMsgType[CMD_ADD_HTLC]
+    assert(cmd.amount == 10_000_000.msat)
+    assert(cmd.fundingFee_opt.contains(LiquidityAds.FundingFee(5_000_000 msat, purchase.txId)))
+    assert(cmd.paymentHash == paymentHash2)
+    channel.expectNoMessage(100 millis)
+
+    val add = UpdateAddHtlc(purchase.channelId, randomHtlcId(), cmd.amount, cmd.paymentHash, cmd.cltvExpiry, cmd.onion, cmd.nextBlindingKey_opt, cmd.confidence, cmd.fundingFee_opt)
+    cmd.replyTo ! RES_ADD_SETTLED(cmd.origin, add, HtlcResult.RemoteFulfill(UpdateFulfillHtlc(purchase.channelId, add.id, preimage2)))
+    verifyFulfilledUpstream(upstream2, preimage2)
+    register.expectNoMessage(100 millis)
+    awaitCond(nodeParams.db.liquidity.getFeeCredit(remoteNodeId) == 0.msat, interval = 100 millis)
+  }
+
+  test("don't relay payments if added to fee credit while signing", Tag(withFeeCredit)) { f =>
+    import f._
+
+    connect(peer)
+
+    val upstream = upstreamChannel(100_000_000 msat, expiryIn, paymentHash)
+    proposeFunding(100_000_000 msat, CltvExpiry(TestConstants.defaultBlockHeight), paymentHash, upstream)
+
+    // The proposal is accepted: we start funding a channel.
+    val requestFunding = LiquidityAds.RequestFunding(
+      200_000 sat,
+      LiquidityAds.FundingRate(10_000 sat, 500_000 sat, 0, 100, 0 sat, 0 sat),
+      LiquidityAds.PaymentDetails.FromFutureHtlc(paymentHash :: Nil)
+    )
+    val open = createOpenChannelMessage(requestFunding)
+    peerConnection.send(peer, open)
+    rateLimiter.expectMsgType[AddOrRejectChannel].replyTo ! PendingChannelsRateLimiter.AcceptOpenChannel
+    channel.expectMsgType[INPUT_INIT_CHANNEL_NON_INITIATOR]
+    channel.expectMsgType[OpenDualFundedChannel]
+
+    // The payment is added to fee credit while we're funding the channel.
+    peerConnection.send(peer, AddFeeCredit(nodeParams.chainHash, preimage))
+    assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 100_000_000.msat)
+    verifyFulfilledUpstream(upstream, preimage)
+
+    // The channel transaction is signed: we invalidate the fee credit and won't relay HTLCs.
+    // We've fulfilled the upstream HTLCs, so we're earning more than our expected fees.
+    val purchase = signLiquidityPurchase(requestFunding.requestedAmount, requestFunding.paymentDetails, fees = requestFunding.fees(open.fundingFeerate, isChannelCreation = true))
+    awaitCond(nodeParams.db.liquidity.getFeeCredit(remoteNodeId) == 0.msat, interval = 100 millis)
+    peer ! ChannelReadyForPayments(channel.ref, remoteNodeId, purchase.channelId, fundingTxIndex = 0)
+    channel.expectNoMessage(100 millis)
+
+    // We don't relay the payment on reconnection either.
+    disconnect(channelCount = 1)
+    connect(peer, protocol.Init(remoteFeaturesWithFeeCredit.initFeatures()))
+    assert(peerConnection.expectMsgType[CurrentFeeCredit].amount == 0.msat)
+    peer ! ChannelReadyForPayments(channel.ref, remoteNodeId, purchase.channelId, fundingTxIndex = 0)
+    channel.expectNoMessage(100 millis)
+    peerConnection.expectNoMessage(100 millis)
+  }
+
   test("don't relay payments too close to expiry") { f =>
     import f._
 
@@ -773,10 +1088,7 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
     channel.expectMsgType[CMD_GET_CHANNEL_INFO].replyTo ! RES_GET_CHANNEL_INFO(remoteNodeId, purchase.channelId, channel.ref, NORMAL, makeChannelData())
     channel.expectNoMessage(100 millis)
 
-    val fwd = register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]]
-    assert(fwd.channelId == upstream.add.channelId)
-    assert(fwd.message.id == upstream.add.id)
-    assert(fwd.message.r == preimage)
+    verifyFulfilledUpstream(upstream, preimage)
     register.expectNoMessage(100 millis)
   }
 
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala
index 4d2858c678..3e67307905 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala
@@ -28,7 +28,7 @@ import fr.acinq.eclair.channel.{ChannelFlags, ChannelTypes}
 import fr.acinq.eclair.json.JsonSerializers
 import fr.acinq.eclair.router.Announcements
 import fr.acinq.eclair.transactions.Scripts
-import fr.acinq.eclair.wire.protocol.ChannelTlv.{ChannelTypeTlv, PushAmountTlv, RequireConfirmedInputsTlv, UpfrontShutdownScriptTlv}
+import fr.acinq.eclair.wire.protocol.ChannelTlv._
 import fr.acinq.eclair.wire.protocol.LightningMessageCodecs._
 import fr.acinq.eclair.wire.protocol.ReplyChannelRangeTlv._
 import org.json4s.jackson.Serialization
@@ -372,7 +372,9 @@ class LightningMessageCodecsSpec extends AnyFunSuite {
       defaultAccept -> defaultEncoded,
       defaultAccept.copy(tlvStream = TlvStream(ChannelTypeTlv(ChannelTypes.StaticRemoteKey()))) -> (defaultEncoded ++ hex"01021000"),
       defaultAccept.copy(tlvStream = TlvStream(ChannelTypeTlv(ChannelTypes.AnchorOutputsZeroFeeHtlcTx()), PushAmountTlv(1729 msat))) -> (defaultEncoded ++ hex"0103401000 fe470000070206c1"),
-      defaultAccept.copy(tlvStream = TlvStream(ChannelTypeTlv(ChannelTypes.StaticRemoteKey()), RequireConfirmedInputsTlv())) -> (defaultEncoded ++ hex"01021000 0200")
+      defaultAccept.copy(tlvStream = TlvStream(ChannelTypeTlv(ChannelTypes.StaticRemoteKey()), RequireConfirmedInputsTlv())) -> (defaultEncoded ++ hex"01021000 0200"),
+      defaultAccept.copy(tlvStream = TlvStream(ChannelTypeTlv(ChannelTypes.AnchorOutputsZeroFeeHtlcTx()), FeeCreditUsedTlv(0 msat))) -> (defaultEncoded ++ hex"0103401000 fda05200"),
+      defaultAccept.copy(tlvStream = TlvStream(ChannelTypeTlv(ChannelTypes.AnchorOutputsZeroFeeHtlcTx()), FeeCreditUsedTlv(1729 msat))) -> (defaultEncoded ++ hex"0103401000 fda0520206c1"),
     )
     testCases.foreach { case (accept, bin) =>
       val decoded = lightningMessageCodec.decode(bin.bits).require.value
@@ -395,10 +397,12 @@ class LightningMessageCodecsSpec extends AnyFunSuite {
       SpliceInit(channelId, (-50_000).sat, FeeratePerKw(500 sat), 0, fundingPubkey) -> hex"9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ffffffffffff3cb0 000001f4 00000000 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
       SpliceInit(channelId, 100_000 sat, 100, FeeratePerKw(2500 sat), fundingPubkey, 0 msat, requireConfirmedInputs = false, Some(LiquidityAds.RequestFunding(100_000 sat, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance))) -> hex"9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000186a0 000009c4 00000064 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fd053b1e00000000000186a0000186a0000186a00190009600000000000000000000",
       SpliceAck(channelId, 25_000 sat, fundingPubkey) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
-      SpliceAck(channelId, 40_000 sat, fundingPubkey, 10_000_000 msat, requireConfirmedInputs = false, None) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000009c40 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fe4700000703989680",
+      SpliceAck(channelId, 40_000 sat, fundingPubkey, 10_000_000 msat, requireConfirmedInputs = false, None, None) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000009c40 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fe4700000703989680",
       SpliceAck(channelId, 0 sat, fundingPubkey) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000000 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
       SpliceAck(channelId, (-25_000).sat, fundingPubkey) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ffffffffffff9e58 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
-      SpliceAck(channelId, 25_000 sat, fundingPubkey, 0 msat, requireConfirmedInputs = false, Some(LiquidityAds.WillFund(fundingRate, hex"deadbeef", ByteVector64.Zeroes))) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fd053b5a000186a0000186a00190009600000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
+      SpliceAck(channelId, 25_000 sat, fundingPubkey, 0 msat, requireConfirmedInputs = false, Some(LiquidityAds.WillFund(fundingRate, hex"deadbeef", ByteVector64.Zeroes)), None) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fd053b5a000186a0000186a00190009600000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
+      SpliceAck(channelId, 25_000 sat, fundingPubkey, TlvStream(ChannelTlv.FeeCreditUsedTlv(0 msat))) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fda05200",
+      SpliceAck(channelId, 25_000 sat, fundingPubkey, TlvStream(ChannelTlv.FeeCreditUsedTlv(1729 msat))) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fda0520206c1",
       SpliceLocked(channelId, fundingTxId) -> hex"908c aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 24e1b2c94c4e734dd5b9c5f3c910fbb6b3b436ced6382c7186056a5a23f14566",
       // @formatter:on
     )
@@ -464,7 +468,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite {
       val open = defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.RequestFundingTlv(request)))
       val openBin = hex"fd053b 1e 00000000000b71b0 0007a120004c4b40044c004b00000000000005dc 0000"
       assert(lightningMessageCodec.encode(open).require.bytes == defaultOpenBin ++ openBin)
-      val Right(willFund) = willFundRates.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request, isChannelCreation = true).map(_.willFund)
+      val Right(willFund) = willFundRates.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request, isChannelCreation = true, None).map(_.willFund)
       val accept = defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund)))
       val acceptBin = hex"fd053b 78 0007a120004c4b40044c004b00000000000005dc 002200202ec38203f4cf37a3b377d9a55c7ae0153c643046dbdbe2ffccfb11b74420103c c57cf393f6bd534472ec08cbfbbc7268501b32f563a21cdf02a99127c4f25168249acd6509f96b2e93843c3b838ee4808c75d0a15ff71ba886fda980b8ca954f"
       assert(lightningMessageCodec.encode(accept).require.bytes == defaultAcceptBin ++ acceptBin)
@@ -480,7 +484,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite {
       val open = defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.RequestFundingTlv(request)))
       val openBin = hex"fd053b 5e 000000000007a120 000186a00007a1200226006400001388000003e8 804080417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734d662b36d54c6d1c2a0227cdc114d12c578c25ab6ec664eebaa440d7e493eba47"
       assert(lightningMessageCodec.encode(open).require.bytes == defaultOpenBin ++ openBin)
-      val Right(willFund) = willFundRates1.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request, isChannelCreation = true).map(_.willFund)
+      val Right(willFund) = willFundRates1.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request, isChannelCreation = true, None).map(_.willFund)
       val accept = defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund)))
       val acceptBin = hex"fd053b 78 000186a00007a1200226006400001388000003e8 002200202ec38203f4cf37a3b377d9a55c7ae0153c643046dbdbe2ffccfb11b74420103c 035875ad2279190f6bfcc75a8bdccafeddfc2700a03587e3621114bf43b60d2c0de977ba0337b163d320471720a683ae211bea07742a2c4204dd5eb0bda75135"
       assert(lightningMessageCodec.encode(accept).require.bytes == defaultAcceptBin ++ acceptBin)
@@ -496,7 +500,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite {
       val open = defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.RequestFundingTlv(request)))
       val openBin = hex"fd053b 5e 000000000007a120 000186a00007a1200226006400001388000003e8 824080417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734d662b36d54c6d1c2a0227cdc114d12c578c25ab6ec664eebaa440d7e493eba47"
       assert(lightningMessageCodec.encode(open).require.bytes == defaultOpenBin ++ openBin)
-      val Right(willFund) = willFundRates1.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request, isChannelCreation = true).map(_.willFund)
+      val Right(willFund) = willFundRates1.validateRequest(nodeKey, randomBytes32(), fundingScript, defaultOpen.fundingFeerate, request, isChannelCreation = true, None).map(_.willFund)
       val accept = defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund)))
       val acceptBin = hex"fd053b 78 000186a00007a1200226006400001388000003e8 002200202ec38203f4cf37a3b377d9a55c7ae0153c643046dbdbe2ffccfb11b74420103c 035875ad2279190f6bfcc75a8bdccafeddfc2700a03587e3621114bf43b60d2c0de977ba0337b163d320471720a683ae211bea07742a2c4204dd5eb0bda75135"
       assert(lightningMessageCodec.encode(accept).require.bytes == defaultAcceptBin ++ acceptBin)
@@ -608,6 +612,24 @@ class LightningMessageCodecsSpec extends AnyFunSuite {
     }
   }
 
+  test("encode/decode fee credit messages") {
+    val preimages = Seq(
+      ByteVector32(hex"6962570ba49642729d77020821f55a492f5df092f3777e75f9740e5b6efec08f"),
+      ByteVector32(hex"4ad834d418faf74ebf7c8a026f2767a41c3a0995c334d7d3dab47737794b0c16"),
+    )
+    val testCases = Seq(
+      AddFeeCredit(Block.RegtestGenesisBlock.hash, preimages.head) -> hex"a055 06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f 6962570ba49642729d77020821f55a492f5df092f3777e75f9740e5b6efec08f",
+      CurrentFeeCredit(Block.RegtestGenesisBlock.hash, 0 msat) -> hex"a056 06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f 0000000000000000",
+      CurrentFeeCredit(Block.RegtestGenesisBlock.hash, 20_000_000 msat) -> hex"a056 06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f 0000000001312d00",
+    )
+    for ((expected, encoded) <- testCases) {
+      val decoded = lightningMessageCodec.decode(encoded.bits).require.value
+      assert(decoded == expected)
+      val reEncoded = lightningMessageCodec.encode(decoded).require.bytes
+      assert(reEncoded == encoded)
+    }
+  }
+
   test("unknown messages") {
     // Non-standard tag number so this message can only be handled by a codec with a fallback
     val unknown = UnknownMessage(tag = 47282, data = ByteVector32.Zeroes.bytes)
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LiquidityAdsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LiquidityAdsSpec.scala
index 67337d123e..54610e73c6 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LiquidityAdsSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LiquidityAdsSpec.scala
@@ -40,7 +40,7 @@ class LiquidityAdsSpec extends AnyFunSuite {
     val fundingRates = LiquidityAds.WillFundRates(fundingRate :: Nil, Set(LiquidityAds.PaymentType.FromChannelBalance))
     val Some(request) = LiquidityAds.requestFunding(500_000 sat, LiquidityAds.PaymentDetails.FromChannelBalance, fundingRates)
     val fundingScript = hex"00202395c9c52c02ca069f1d56a3c6124bf8b152a617328c76e6b31f83ace370c2ff"
-    val Right(willFund) = fundingRates.validateRequest(nodeKey, randomBytes32(), fundingScript, FeeratePerKw(1000 sat), request, isChannelCreation = true).map(_.willFund)
+    val Right(willFund) = fundingRates.validateRequest(nodeKey, randomBytes32(), fundingScript, FeeratePerKw(1000 sat), request, isChannelCreation = true, None).map(_.willFund)
     assert(willFund.fundingRate == fundingRate)
     assert(willFund.fundingScript == fundingScript)
     assert(willFund.signature == ByteVector64.fromValidHex("a53106bd20027b0215480ff0b06b2bf9324bb257c2a0e74c2604ec347493f90d3a975d56a68b21a6cc48d6763d96f70e1d630dd1720cf6b7314d4304050fe265"))