Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for funding_fee_credit #2875

Merged
merged 2 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions eclair-core/src/main/scala/fr/acinq/eclair/Features.scala
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,14 @@ object Features {
val mandatory = 560
}

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

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

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

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

for {
script_opt <- extractShutdownScript(open.temporaryChannelId, localFeatures, remoteFeatures, open.upfrontShutdownScript_opt)
willFund_opt <- LiquidityAds.validateRequest(nodeParams.privateKey, open.temporaryChannelId, fundingScript, open.fundingFeerate, 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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -952,7 +952,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
val parentCommitment = d.commitments.latest.commitment
val localFundingPubKey = nodeParams.channelKeyManager.fundingPublicKey(d.commitments.params.localParams.fundingKeyPath, parentCommitment.fundingTxIndex + 1).publicKey
val fundingScript = Funding.makeFundingPubKeyScript(localFundingPubKey, msg.fundingPubKey)
LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, 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)
Expand All @@ -963,7 +963,8 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
fundingPubKey = localFundingPubKey,
pushAmount = 0.msat,
requireConfirmedInputs = nodeParams.channelConf.requireConfirmedInputsForDualFunding,
willFund_opt = willFund_opt.map(_.willFund)
willFund_opt = willFund_opt.map(_.willFund),
feeCreditUsed_opt = msg.useFeeCredit_opt
)
val fundingParams = InteractiveTxParams(
channelId = d.channelId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers {
Some(ChannelTlv.ChannelTypeTlv(d.init.channelType)),
if (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(
Expand Down Expand Up @@ -547,7 +548,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers {
stay() using d.copy(rbfStatus = RbfStatus.RbfAborted) sending TxAbort(d.channelId, InvalidRbfAttemptTooSoon(d.channelId, d.latestFundingTx.createdAt, d.latestFundingTx.createdAt + nodeParams.channelConf.remoteRbfLimits.attemptDeltaBlocks).getMessage)
} else {
val fundingScript = d.commitments.latest.commitInput.txOut.publicKeyScript
LiquidityAds.validateRequest(nodeParams.privateKey, d.channelId, fundingScript, msg.feerate, 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

}
10 changes: 10 additions & 0 deletions eclair-core/src/main/scala/fr/acinq/eclair/db/LiquidityDb.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
}
}

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

Expand Down
Loading
Loading