1
0
mirror of https://github.com/ACINQ/eclair.git synced 2024-11-19 01:43:22 +01:00

Add support for funding_fee_credit (#2875)

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

This feature makes more efficient use of on-chain transactions by
trusting that the seller will honor our fee credit in the future. The
fee credit takes precedence over other ways of paying the fees (from
the channel balance or future HTLCs), which guarantees that the fee
credit eventually converges to 0.

Co-authored-by: Pierre-Marie Padiou <pm47@users.noreply.github.com>
This commit is contained in:
Bastien Teinturier 2024-09-26 14:21:05 +02:00 committed by GitHub
parent de42c8aa1b
commit f11f922c6b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 880 additions and 116 deletions

View File

@ -332,6 +332,14 @@ object Features {
val mandatory = 560 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( val knownFeatures: Set[Feature] = Set(
DataLossProtect, DataLossProtect,
InitialRoutingSync, InitialRoutingSync,
@ -358,7 +366,8 @@ object Features {
TrampolinePaymentPrototype, TrampolinePaymentPrototype,
AsyncPaymentPrototype, AsyncPaymentPrototype,
SplicePrototype, SplicePrototype,
OnTheFlyFunding OnTheFlyFunding,
FundingFeeCredit
) )
// Features may depend on other features, as specified in Bolt 9. // Features may depend on other features, as specified in Bolt 9.
@ -372,7 +381,8 @@ object Features {
TrampolinePaymentPrototype -> (PaymentSecret :: Nil), TrampolinePaymentPrototype -> (PaymentSecret :: Nil),
KeySend -> (VariableLengthOnion :: Nil), KeySend -> (VariableLengthOnion :: Nil),
AsyncPaymentPrototype -> (TrampolinePaymentPrototype :: Nil), AsyncPaymentPrototype -> (TrampolinePaymentPrototype :: Nil),
OnTheFlyFunding -> (SplicePrototype :: Nil) OnTheFlyFunding -> (SplicePrototype :: Nil),
FundingFeeCredit -> (OnTheFlyFunding :: Nil)
) )
case class FeatureException(message: String) extends IllegalArgumentException(message) case class FeatureException(message: String) extends IllegalArgumentException(message)

View File

@ -171,7 +171,7 @@ object Helpers {
for { for {
script_opt <- extractShutdownScript(open.temporaryChannelId, localFeatures, remoteFeatures, open.upfrontShutdownScript_opt) 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) } yield (channelFeatures, script_opt, willFund_opt)
} }

View File

@ -952,7 +952,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
val parentCommitment = d.commitments.latest.commitment val parentCommitment = d.commitments.latest.commitment
val localFundingPubKey = nodeParams.channelKeyManager.fundingPublicKey(d.commitments.params.localParams.fundingKeyPath, parentCommitment.fundingTxIndex + 1).publicKey val localFundingPubKey = nodeParams.channelKeyManager.fundingPublicKey(d.commitments.params.localParams.fundingKeyPath, parentCommitment.fundingTxIndex + 1).publicKey
val fundingScript = Funding.makeFundingPubKeyScript(localFundingPubKey, msg.fundingPubKey) 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) => case Left(t) =>
log.warning("rejecting splice request with invalid liquidity ads: {}", t.getMessage) log.warning("rejecting splice request with invalid liquidity ads: {}", t.getMessage)
stay() using d.copy(spliceStatus = SpliceStatus.SpliceAborted) sending TxAbort(d.channelId, 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, fundingPubKey = localFundingPubKey,
pushAmount = 0.msat, pushAmount = 0.msat,
requireConfirmedInputs = nodeParams.channelConf.requireConfirmedInputsForDualFunding, requireConfirmedInputs = nodeParams.channelConf.requireConfirmedInputsForDualFunding,
willFund_opt = willFund_opt.map(_.willFund) willFund_opt = willFund_opt.map(_.willFund),
feeCreditUsed_opt = msg.useFeeCredit_opt
) )
val fundingParams = InteractiveTxParams( val fundingParams = InteractiveTxParams(
channelId = d.channelId, channelId = d.channelId,

View File

@ -180,6 +180,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers {
Some(ChannelTlv.ChannelTypeTlv(d.init.channelType)), Some(ChannelTlv.ChannelTypeTlv(d.init.channelType)),
if (d.init.requireConfirmedInputs) Some(ChannelTlv.RequireConfirmedInputsTlv()) else None, if (d.init.requireConfirmedInputs) Some(ChannelTlv.RequireConfirmedInputsTlv()) else None,
willFund_opt.map(l => ChannelTlv.ProvideFundingTlv(l.willFund)), 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)), d.init.pushAmount_opt.map(amount => ChannelTlv.PushAmountTlv(amount)),
).flatten ).flatten
val accept = AcceptDualFundedChannel( 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) 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 { } else {
val fundingScript = d.commitments.latest.commitInput.txOut.publicKeyScript 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) => case Left(t) =>
log.warning("rejecting rbf attempt: invalid liquidity ads request ({})", t.getMessage) log.warning("rejecting rbf attempt: invalid liquidity ads request ({})", t.getMessage)
stay() using d.copy(rbfStatus = RbfStatus.RbfAborted) sending TxAbort(d.channelId, t.getMessage) stay() using d.copy(rbfStatus = RbfStatus.RbfAborted) sending TxAbort(d.channelId, t.getMessage)

View File

@ -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.Transactions.{CommitTx, HtlcTx, InputInfo, TxOwner}
import fr.acinq.eclair.transactions.{CommitmentSpec, DirectedHtlc, Scripts, Transactions} import fr.acinq.eclair.transactions.{CommitmentSpec, DirectedHtlc, Scripts, Transactions}
import fr.acinq.eclair.wire.protocol._ 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 scodec.bits.ByteVector
import scala.concurrent.{ExecutionContext, Future} 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. // 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 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 { liquidityPurchase_opt.map(l => l.paymentDetails match {
// The initiator of the interactive-tx is the liquidity buyer (if liquidity ads is used). // 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. // Fees will be paid later, when relaying HTLCs.
case _: LiquidityAds.PaymentDetails.FromFutureHtlc | _: LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage => 0.sat case _: LiquidityAds.PaymentDetails.FromFutureHtlc | _: LiquidityAds.PaymentDetails.FromFutureHtlcWithPreimage => 0 msat
}).getOrElse(0 sat) }).getOrElse(0 msat)
} }
} }
@ -744,6 +749,16 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
return Left(InvalidCompleteInteractiveTx(fundingParams.channelId)) 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 { previousTransactions.headOption match {
case Some(previousTx) => case Some(previousTx) =>
// This is an RBF attempt: even if our peer does not contribute to the feerate increase, we'd like to broadcast // This is an RBF attempt: even if our peer does not contribute to the feerate increase, we'd like to broadcast

View File

@ -463,4 +463,19 @@ case class DualLiquidityDb(primary: LiquidityDb, secondary: LiquidityDb) extends
primary.getOnTheFlyFundingPreimage(paymentHash) 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)
}
} }

View File

@ -20,6 +20,7 @@ import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.bitcoin.scalacompat.{ByteVector32, TxId} import fr.acinq.bitcoin.scalacompat.{ByteVector32, TxId}
import fr.acinq.eclair.channel.{ChannelLiquidityPurchased, LiquidityPurchase} import fr.acinq.eclair.channel.{ChannelLiquidityPurchased, LiquidityPurchase}
import fr.acinq.eclair.payment.relay.OnTheFlyFunding import fr.acinq.eclair.payment.relay.OnTheFlyFunding
import fr.acinq.eclair.{MilliSatoshi, TimestampMilli}
/** /**
* Created by t-bast on 13/09/2024. * 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. */ /** Check if we received the preimage for the given payment hash of an on-the-fly payment. */
def getOnTheFlyFundingPreimage(paymentHash: ByteVector32): Option[ByteVector32] 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
} }

View File

@ -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.db.pg.PgUtils.PgLock.NoLock.withLock
import fr.acinq.eclair.payment.relay.OnTheFlyFunding import fr.acinq.eclair.payment.relay.OnTheFlyFunding
import fr.acinq.eclair.wire.protocol.LiquidityAds 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 grizzled.slf4j.Logging
import scodec.bits.BitVector import scodec.bits.BitVector
@ -58,6 +58,7 @@ class PgLiquidityDb(implicit ds: DataSource) extends LiquidityDb with Logging {
// On-the-fly funding. // 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.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.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. // Indexes.
statement.executeUpdate("CREATE INDEX liquidity_purchases_node_id_idx ON liquidity.purchases(node_id)") 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 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) { override def addPendingOnTheFlyFunding(remoteNodeId: Crypto.PublicKey, pending: OnTheFlyFunding.Pending): Unit = withMetrics("liquidity/add-pending-on-the-fly-funding", DbBackends.Postgres) {
pending.status match { pending.status match {
case _: OnTheFlyFunding.Status.Proposed => () case _: OnTheFlyFunding.Status.Proposed => ()
case _: OnTheFlyFunding.Status.AddedToFeeCredit => ()
case status: OnTheFlyFunding.Status.Funded => withLock { pg => 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 => 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) 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
}
}
}
}
} }

View File

@ -53,6 +53,7 @@ class SqliteLiquidityDb(val sqlite: Connection) extends LiquidityDb with Logging
// On-the-fly funding. // 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_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 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. // Indexes.
statement.executeUpdate("CREATE INDEX liquidity_purchases_node_id_idx ON liquidity_purchases(node_id)") 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 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) { override def addPendingOnTheFlyFunding(remoteNodeId: Crypto.PublicKey, pending: OnTheFlyFunding.Pending): Unit = withMetrics("liquidity/add-pending-on-the-fly-funding", DbBackends.Sqlite) {
pending.status match { pending.status match {
case _: OnTheFlyFunding.Status.Proposed => () case _: OnTheFlyFunding.Status.Proposed => ()
case _: OnTheFlyFunding.Status.AddedToFeeCredit => ()
case status: OnTheFlyFunding.Status.Funded => 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 => 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) 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
}
}
}
} }

View File

@ -75,6 +75,7 @@ object Monitoring {
val Rejected = "rejected" val Rejected = "rejected"
val Expired = "expired" val Expired = "expired"
val Timeout = "timeout" val Timeout = "timeout"
val AddedToFeeCredit = "added-to-fee-credit"
val Funded = "funded" val Funded = "funded"
val RelaySucceeded = "relay-succeeded" val RelaySucceeded = "relay-succeeded"

View File

@ -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) { val maxHtlcValueInFlightMsat = if (unlimitedMaxHtlcValueInFlight) {
// We don't want to impose limits on the amount in flight, typically to allow fully emptying the channel. // We don't want to impose limits on the amount in flight, typically to allow fully emptying the channel.
21e6.btc.toMilliSatoshi 21e6.btc.toMilliSatoshi
@ -104,7 +104,7 @@ object OpenChannelInterceptor {
toSelfDelay = nodeParams.channelConf.toRemoteDelay, // we choose their delay toSelfDelay = nodeParams.channelConf.toRemoteDelay, // we choose their delay
maxAcceptedHtlcs = nodeParams.channelConf.maxAcceptedHtlcs, maxAcceptedHtlcs = nodeParams.channelConf.maxAcceptedHtlcs,
isChannelOpener = isChannelOpener, isChannelOpener = isChannelOpener,
paysCommitTxFees = isChannelOpener, paysCommitTxFees = paysCommitTxFees,
upfrontShutdownScript_opt = upfrontShutdownScript_opt, upfrontShutdownScript_opt = upfrontShutdownScript_opt,
walletStaticPaymentBasepoint = walletStaticPaymentBasepoint_opt, walletStaticPaymentBasepoint = walletStaticPaymentBasepoint_opt,
initFeatures = initFeatures 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 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 dualFunded = Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.DualFunding)
val upfrontShutdownScript = Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.UpfrontShutdownScript) 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) peer ! Peer.SpawnChannelInitiator(request.replyTo, request.open, ChannelConfig.standard, channelType, localParams)
waitForRequest() waitForRequest()
} }
@ -161,18 +161,24 @@ private class OpenChannelInterceptor(peer: ActorRef[Any],
case Right(channelType) => case Right(channelType) =>
val dualFunded = Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.DualFunding) val dualFunded = Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.DualFunding)
val upfrontShutdownScript = Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.UpfrontShutdownScript) 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: // We only accept paying the commit fees if:
// - our peer supports on-the-fly funding, indicating that they're a mobile wallet // - our peer supports on-the-fly funding, indicating that they're a mobile wallet
// - they are purchasing liquidity for this channel // - they are purchasing liquidity for this channel
val nonInitiatorPaysCommitTxFees = request.channelFlags.nonInitiatorPaysCommitFees && val nonInitiatorPaysCommitTxFees = request.channelFlags.nonInitiatorPaysCommitFees &&
Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.OnTheFlyFunding) && Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.OnTheFlyFunding) &&
request.open.fold(_ => false, _.requestFunding_opt.isDefined) request.open.fold(_ => false, _.requestFunding_opt.isDefined)
if (nonInitiatorPaysCommitTxFees) { val localParams = createLocalParams(
checkRateLimits(request, channelType, localParams.copy(paysCommitTxFees = true)) nodeParams,
} else { request.localFeatures,
checkRateLimits(request, channelType, localParams) upfrontShutdownScript,
} channelType,
isChannelOpener = false,
paysCommitTxFees = nonInitiatorPaysCommitTxFees,
dualFunded = dualFunded,
fundingAmount = request.fundingAmount,
disableMaxHtlcValueInFlight = false
)
checkRateLimits(request, channelType, localParams)
case Left(ex) => case Left(ex) =>
context.log.warn(s"ignoring remote channel open: ${ex.getMessage}") context.log.warn(s"ignoring remote channel open: ${ex.getMessage}")
sendFailure(ex.getMessage, request) 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 val pubkey_opt = if (upfrontShutdownScript || channelType.paysDirectlyToWallet) Some(wallet.getP2wpkhPubkey()) else None
makeChannelParams( makeChannelParams(
nodeParams, initFeatures, nodeParams, initFeatures,
if (upfrontShutdownScript) Some(Script.write(Script.pay2wpkh(pubkey_opt.get))) else None, if (upfrontShutdownScript) Some(Script.write(Script.pay2wpkh(pubkey_opt.get))) else None,
if (channelType.paysDirectlyToWallet) Some(pubkey_opt.get) else None, if (channelType.paysDirectlyToWallet) Some(pubkey_opt.get) else None,
isChannelOpener = isChannelOpener, isChannelOpener = isChannelOpener,
paysCommitTxFees = paysCommitTxFees,
dualFunded = dualFunded, dualFunded = dualFunded,
fundingAmount, fundingAmount,
disableMaxHtlcValueInFlight disableMaxHtlcValueInFlight

View File

@ -44,8 +44,7 @@ import fr.acinq.eclair.remote.EclairInternalsSerializer.RemoteTypes
import fr.acinq.eclair.router.Router import fr.acinq.eclair.router.Router
import fr.acinq.eclair.wire.protocol import fr.acinq.eclair.wire.protocol
import fr.acinq.eclair.wire.protocol.FailureMessageCodecs.createBadOnionFailure import fr.acinq.eclair.wire.protocol.FailureMessageCodecs.createBadOnionFailure
import fr.acinq.eclair.wire.protocol.LiquidityAds.PaymentDetails 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}
import fr.acinq.eclair.wire.protocol.{Error, HasChannelId, HasTemporaryChannelId, LightningMessage, LiquidityAds, NodeAddress, OnTheFlyFundingFailureMessage, OnionMessage, OnionRoutingPacket, RoutingMessage, SpliceInit, UnknownMessage, Warning, WillAddHtlc, WillFailHtlc, WillFailMalformedHtlc}
/** /**
* This actor represents a logical peer. There is one [[Peer]] per unique remote node id at all time. * 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._ import Peer._
private var pendingOnTheFlyFunding = Map.empty[ByteVector32, OnTheFlyFunding.Pending] 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[CurrentFeerates])
context.system.eventStream.subscribe(self, classOf[CurrentBlockHeight]) context.system.eventStream.subscribe(self, classOf[CurrentBlockHeight])
@ -100,7 +100,7 @@ class Peer(val nodeParams: NodeParams,
val channelIds = d.channels.filter(_._2 == actor).keys val channelIds = d.channels.filter(_._2 == actor).keys
log.info(s"channel closed: channelId=${channelIds.mkString("/")}") log.info(s"channel closed: channelId=${channelIds.mkString("/")}")
val channels1 = d.channels -- channelIds val channels1 = d.channels -- channelIds
if (channels1.isEmpty && !pendingSignedOnTheFlyFunding()) { if (channels1.isEmpty && canForgetPendingOnTheFlyFunding()) {
log.info("that was the last open channel") log.info("that was the last open channel")
context.system.eventStream.publish(LastChannelClosed(self, remoteNodeId)) context.system.eventStream.publish(LastChannelClosed(self, remoteNodeId))
// We have no existing channels or pending signed transaction, we can forget about this peer. // 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))) { Logs.withMdc(diagLog)(Logs.mdc(category_opt = Some(Logs.LogCategory.CONNECTION))) {
log.debug("connection lost while negotiating 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. // We have no existing channels or pending signed transaction, we can forget about this peer.
stopPeer() stopPeer()
} else { } else {
@ -214,7 +214,7 @@ class Peer(val nodeParams: NodeParams,
case Event(SpawnChannelNonInitiator(open, channelConfig, channelType, addFunding_opt, localParams, peerConnection), d: ConnectedData) => case Event(SpawnChannelNonInitiator(open, channelConfig, channelType, addFunding_opt, localParams, peerConnection), d: ConnectedData) =>
val temporaryChannelId = open.fold(_.temporaryChannelId, _.temporaryChannelId) val temporaryChannelId = open.fold(_.temporaryChannelId, _.temporaryChannelId)
if (peerConnection == d.peerConnection) { if (peerConnection == d.peerConnection) {
OnTheFlyFunding.validateOpen(open, pendingOnTheFlyFunding) match { OnTheFlyFunding.validateOpen(open, pendingOnTheFlyFunding, feeCredit.getOrElse(0 msat)) match {
case reject: OnTheFlyFunding.ValidationResult.Reject => case reject: OnTheFlyFunding.ValidationResult.Reject =>
log.warning("rejecting on-the-fly channel: {}", reject.cancel.toAscii) log.warning("rejecting on-the-fly channel: {}", reject.cancel.toAscii)
self ! Peer.OutgoingMessage(reject.cancel, d.peerConnection) self ! Peer.OutgoingMessage(reject.cancel, d.peerConnection)
@ -231,7 +231,10 @@ class Peer(val nodeParams: NodeParams,
case Right(open) => case Right(open) =>
val requireConfirmedInputs = nodeParams.channelConf.requireConfirmedInputsForDualFunding 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 ! 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) fulfillOnTheFlyFundingHtlcs(accept.preimages)
stay() using d.copy(channels = d.channels + (TemporaryChannelId(temporaryChannelId) -> channel)) 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), proposed = pending.proposed :+ OnTheFlyFunding.Proposal(htlc, cmd.upstream),
status = OnTheFlyFunding.Status.Proposed(timer) 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 => 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) 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)) 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) 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) 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 => case status: OnTheFlyFunding.Status.Funded =>
log.warning("ignoring will_fail_htlc: on-the-fly funding already signed with txId={}", status.txId) 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) 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() Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Expired).increment()
pendingOnTheFlyFunding -= timeout.paymentHash pendingOnTheFlyFunding -= timeout.paymentHash
self ! Peer.OutgoingMessage(Warning(s"on-the-fly funding proposal timed out for payment_hash=${timeout.paymentHash}"), d.peerConnection) 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 => case status: OnTheFlyFunding.Status.Funded =>
log.warning("ignoring on-the-fly funding proposal timeout, already funded with txId={}", status.txId) 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() 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) => case Event(msg: SpliceInit, d: ConnectedData) =>
d.channels.get(FinalChannelId(msg.channelId)) match { d.channels.get(FinalChannelId(msg.channelId)) match {
case Some(channel) => 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 => case reject: OnTheFlyFunding.ValidationResult.Reject =>
log.warning("rejecting on-the-fly splice: {}", reject.cancel.toAscii) log.warning("rejecting on-the-fly splice: {}", reject.cancel.toAscii)
self ! Peer.OutgoingMessage(reject.cancel, d.peerConnection) self ! Peer.OutgoingMessage(reject.cancel, d.peerConnection)
cancelUnsignedOnTheFlyFunding(reject.paymentHashes) cancelUnsignedOnTheFlyFunding(reject.paymentHashes)
case accept: OnTheFlyFunding.ValidationResult.Accept => case accept: OnTheFlyFunding.ValidationResult.Accept =>
fulfillOnTheFlyFundingHtlcs(accept.preimages) 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) case None => replyUnknownChannel(d.peerConnection, msg.channelId)
} }
@ -349,6 +401,7 @@ class Peer(val nodeParams: NodeParams,
case (paymentHash, pending) => case (paymentHash, pending) =>
pending.status match { pending.status match {
case _: OnTheFlyFunding.Status.Proposed => () case _: OnTheFlyFunding.Status.Proposed => ()
case _: OnTheFlyFunding.Status.AddedToFeeCredit => ()
case status: OnTheFlyFunding.Status.Funded => case status: OnTheFlyFunding.Status.Funded =>
context.child(paymentHash.toHex) match { context.child(paymentHash.toHex) match {
case Some(_) => log.debug("already relaying payment_hash={}", paymentHash) 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))) { Logs.withMdc(diagLog)(Logs.mdc(category_opt = Some(Logs.LogCategory.CONNECTION))) {
log.debug("connection lost") 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. // We have no existing channels or pending signed transaction, we can forget about this peer.
stopPeer() stopPeer()
} else { } else {
@ -506,16 +559,20 @@ class Peer(val nodeParams: NodeParams,
val expired = pendingOnTheFlyFunding.filter { val expired = pendingOnTheFlyFunding.filter {
case (_, pending) => pending.proposed.exists(_.htlc.expiry.blockHeight <= current.blockHeight) 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 { expired.foreach {
case (paymentHash, pending) => pending.status match { case (paymentHash, pending) => pending.status match {
case _: OnTheFlyFunding.Status.Proposed => () case _: OnTheFlyFunding.Status.Proposed =>
case _: OnTheFlyFunding.Status.Funded => nodeParams.db.liquidity.removePendingOnTheFlyFunding(remoteNodeId, paymentHash) 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) pendingOnTheFlyFunding = pendingOnTheFlyFunding.removedAll(expired.keys)
@ -524,22 +581,34 @@ class Peer(val nodeParams: NodeParams,
case _ => stay() 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 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. // 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 // 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 // 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. // funded the channel, but it closed before we could relay the HTLCs.
val (paymentHashes, fees) = e.purchase.paymentDetails match { val (paymentHashes, feesOwed) = e.purchase.paymentDetails match {
case PaymentDetails.FromChannelBalance => (Nil, 0 sat) case LiquidityAds.PaymentDetails.FromChannelBalance => (Nil, 0 msat)
case p: PaymentDetails.FromChannelBalanceForFutureHtlc => (p.paymentHashes, 0 sat) case p: LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc => (p.paymentHashes, 0 msat)
case p: PaymentDetails.FromFutureHtlc => (p.paymentHashes, e.purchase.fees.total) case p: LiquidityAds.PaymentDetails.FromFutureHtlc => (p.paymentHashes, e.purchase.fees.total - feeCreditUsed)
case p: PaymentDetails.FromFutureHtlcWithPreimage => (p.preimages.map(preimage => Crypto.sha256(preimage)), e.purchase.fees.total) 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 // 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 // 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. // 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 pendingOnTheFlyFunding
.filter { case (paymentHash, _) => paymentHashes.contains(paymentHash) } .filter { case (paymentHash, _) => paymentHashes.contains(paymentHash) }
.values.toSeq .values.toSeq
@ -556,6 +625,17 @@ class Peer(val nodeParams: NodeParams,
Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Funded).increment() Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Funded).increment()
nodeParams.db.liquidity.addPendingOnTheFlyFunding(remoteNodeId, payment1) nodeParams.db.liquidity.addPendingOnTheFlyFunding(remoteNodeId, payment1)
pendingOnTheFlyFunding += payment.paymentHash -> 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 => case status: OnTheFlyFunding.Status.Funded =>
log.warning("liquidity purchase was already signed for payment_hash={} (previousTxId={}, currentTxId={})", payment.paymentHash, status.txId, e.txId) 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 = { 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) log.debug("got authenticated connection to address {}", connectionReady.address)
if (connectionReady.outgoing) { if (connectionReady.outgoing) {
@ -652,6 +732,16 @@ class Peer(val nodeParams: NodeParams,
// We tell our peer what our current feerates are. // We tell our peer what our current feerates are.
connectionReady.peerConnection ! nodeParams.recommendedFeerates(remoteNodeId, connectionReady.localInit.features, connectionReady.remoteInit.features) 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) 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) => case (paymentHash, pending) if paymentHashes.contains(paymentHash) =>
pending.status match { pending.status match {
case status: OnTheFlyFunding.Status.Proposed => case status: OnTheFlyFunding.Status.Proposed =>
log.info("cancelling on-the-fly funding for payment_hash={}", paymentHash)
status.timer.cancel() status.timer.cancel()
pending.createFailureCommands().foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
true 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 _: OnTheFlyFunding.Status.Funded => false
} }
case _ => 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) 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. */ /** Return true if we can forget pending on-the-fly funding transactions and stop ourselves. */
private def pendingSignedOnTheFlyFunding(): Boolean = { private def canForgetPendingOnTheFlyFunding(): Boolean = {
pendingOnTheFlyFunding.exists { pendingOnTheFlyFunding.forall {
case (_, pending) => pending.status match { case (_, pending) => pending.status match {
case _: OnTheFlyFunding.Status.Proposed => false case _: OnTheFlyFunding.Status.Proposed => true
case _: OnTheFlyFunding.Status.Funded => 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
} }
} }
} }

View File

@ -27,7 +27,7 @@ import fr.acinq.eclair.channel._
import fr.acinq.eclair.crypto.Sphinx import fr.acinq.eclair.crypto.Sphinx
import fr.acinq.eclair.wire.protocol.LiquidityAds.PaymentDetails import fr.acinq.eclair.wire.protocol.LiquidityAds.PaymentDetails
import fr.acinq.eclair.wire.protocol._ 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 scodec.bits.ByteVector
import scala.concurrent.duration.FiniteDuration import scala.concurrent.duration.FiniteDuration
@ -45,6 +45,8 @@ object OnTheFlyFunding {
object Status { object Status {
/** We sent will_add_htlc, but didn't fund a transaction yet. */ /** We sent will_add_htlc, but didn't fund a transaction yet. */
case class Proposed(timer: Cancellable) extends Status 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 * 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. * 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) { case class Pending(proposed: Seq[Proposal], status: Status) {
val paymentHash = proposed.head.htlc.paymentHash val paymentHash = proposed.head.htlc.paymentHash
val expiry = proposed.map(_.htlc.expiry).min val expiry = proposed.map(_.htlc.expiry).min
val amountOut = proposed.map(_.htlc.amount).sum
/** Maximum fees that can be collected from this HTLC set. */ /** Maximum fees that can be collected from this HTLC set. */
def maxFees(htlcMinimum: MilliSatoshi): MilliSatoshi = proposed.map(_.maxFees(htlcMinimum)).sum 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. */ /** 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 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. */ /** 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 // @formatter:on
/** Validate an incoming channel that may use on-the-fly funding. */ /** 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 { open match {
case Left(_) => ValidationResult.Accept(Set.empty) case Left(_) => ValidationResult.Accept(Set.empty, None)
case Right(open) => open.requestFunding_opt match { case Right(open) => open.requestFunding_opt match {
case Some(requestFunding) => validate(open.temporaryChannelId, requestFunding, isChannelCreation = true, open.fundingFeerate, open.htlcMinimum, pendingOnTheFlyFunding) case Some(requestFunding) => validate(open.temporaryChannelId, requestFunding, isChannelCreation = true, open.fundingFeerate, open.htlcMinimum, pendingOnTheFlyFunding, feeCredit)
case None => ValidationResult.Accept(Set.empty) case None => ValidationResult.Accept(Set.empty, None)
} }
} }
} }
/** Validate an incoming splice that may use on-the-fly funding. */ /** 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 { splice.requestFunding_opt match {
case Some(requestFunding) => validate(splice.channelId, requestFunding, isChannelCreation = false, splice.feerate, htlcMinimum, pendingOnTheFlyFunding) case Some(requestFunding) => validate(splice.channelId, requestFunding, isChannelCreation = false, splice.feerate, htlcMinimum, pendingOnTheFlyFunding, feeCredit)
case None => ValidationResult.Accept(Set.empty) case None => ValidationResult.Accept(Set.empty, None)
} }
} }
@ -134,7 +137,8 @@ object OnTheFlyFunding {
isChannelCreation: Boolean, isChannelCreation: Boolean,
feerate: FeeratePerKw, feerate: FeeratePerKw,
htlcMinimum: MilliSatoshi, htlcMinimum: MilliSatoshi,
pendingOnTheFlyFunding: Map[ByteVector32, Pending]): ValidationResult = { pendingOnTheFlyFunding: Map[ByteVector32, Pending],
feeCredit: MilliSatoshi): ValidationResult = {
val paymentHashes = requestFunding.paymentDetails match { val paymentHashes = requestFunding.paymentDetails match {
case PaymentDetails.FromChannelBalance => Nil case PaymentDetails.FromChannelBalance => Nil
case PaymentDetails.FromChannelBalanceForFutureHtlc(paymentHashes) => paymentHashes case PaymentDetails.FromChannelBalanceForFutureHtlc(paymentHashes) => paymentHashes
@ -145,17 +149,24 @@ object OnTheFlyFunding {
val totalPaymentAmount = pending.flatMap(_.proposed.map(_.htlc.amount)).sum 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. // 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 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 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 { 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 _ if requestFunding.requestedAmount.toMilliSatoshi < totalPaymentAmount => ValidationResult.Reject(cancelAmountTooLow, paymentHashes.toSet)
case _: PaymentDetails.FromChannelBalanceForFutureHtlc => ValidationResult.Accept(Set.empty) case _: PaymentDetails.FromChannelBalanceForFutureHtlc => ValidationResult.Accept(Set.empty, useFeeCredit_opt)
case _: PaymentDetails.FromFutureHtlc if availableAmountForFees < fees.total => ValidationResult.Reject(cancelFeesTooLow, paymentHashes.toSet) case _: PaymentDetails.FromFutureHtlc if availableAmountForFees < feesOwed => ValidationResult.Reject(cancelFeesTooLow, paymentHashes.toSet)
case _: PaymentDetails.FromFutureHtlc => ValidationResult.Accept(Set.empty) case _: PaymentDetails.FromFutureHtlc => ValidationResult.Accept(Set.empty, useFeeCredit_opt)
case _: PaymentDetails.FromFutureHtlcWithPreimage if availableAmountForFees < fees.total => ValidationResult.Reject(cancelFeesTooLow, paymentHashes.toSet) case _: PaymentDetails.FromFutureHtlcWithPreimage if availableAmountForFees < feesOwed => ValidationResult.Reject(cancelFeesTooLow, paymentHashes.toSet)
case p: PaymentDetails.FromFutureHtlcWithPreimage => ValidationResult.Accept(p.preimages.toSet) case p: PaymentDetails.FromFutureHtlcWithPreimage => ValidationResult.Accept(p.preimages.toSet, useFeeCredit_opt)
} }
} }

View File

@ -74,10 +74,21 @@ object ChannelTlv {
val provideFundingCodec: Codec[ProvideFundingTlv] = tlvField(LiquidityAds.Codecs.willFund) 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 case class PushAmountTlv(amount: MilliSatoshi) extends OpenDualFundedChannelTlv with AcceptDualFundedChannelTlv with SpliceInitTlv with SpliceAckTlv
val pushAmountCodec: Codec[PushAmountTlv] = tlvField(tmillisatoshi) 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 { object OpenChannelTlv {
@ -169,6 +180,7 @@ object SpliceAckTlv {
.typecase(UInt64(2), requireConfirmedInputsCodec) .typecase(UInt64(2), requireConfirmedInputsCodec)
// We use a temporary TLV while the spec is being reviewed. // We use a temporary TLV while the spec is being reviewed.
.typecase(UInt64(1339), provideFundingCodec) .typecase(UInt64(1339), provideFundingCodec)
.typecase(UInt64(41042), feeCreditUsedCodec)
.typecase(UInt64(0x47000007), tlvField(tmillisatoshi.as[PushAmountTlv])) .typecase(UInt64(0x47000007), tlvField(tmillisatoshi.as[PushAmountTlv]))
) )
} }
@ -187,6 +199,7 @@ object AcceptDualFundedChannelTlv {
.typecase(UInt64(2), requireConfirmedInputsCodec) .typecase(UInt64(2), requireConfirmedInputsCodec)
// We use a temporary TLV while the spec is being reviewed. // We use a temporary TLV while the spec is being reviewed.
.typecase(UInt64(1339), provideFundingCodec) .typecase(UInt64(1339), provideFundingCodec)
.typecase(UInt64(41042), feeCreditUsedCodec)
.typecase(UInt64(0x47000007), pushAmountCodec) .typecase(UInt64(0x47000007), pushAmountCodec)
) )

View File

@ -460,6 +460,14 @@ object LightningMessageCodecs {
("paymentHashes" | listOfN(uint16, bytes32)) :: ("paymentHashes" | listOfN(uint16, bytes32)) ::
("reason" | varsizebinarydata)).as[CancelOnTheFlyFunding] ("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] = ( val unknownMessageCodec: Codec[UnknownMessage] = (
("tag" | uint16) :: ("tag" | uint16) ::
("message" | bytes) ("message" | bytes)
@ -517,6 +525,10 @@ object LightningMessageCodecs {
.typecase(41043, willFailMalformedHtlcCodec) .typecase(41043, willFailMalformedHtlcCodec)
.typecase(41044, cancelOnTheFlyFundingCodec) .typecase(41044, cancelOnTheFlyFundingCodec)
// //
//
.typecase(41045, addFeeCreditCodec)
.typecase(41046, currentFeeCreditCodec)
//
.typecase(37000, spliceInitCodec) .typecase(37000, spliceInitCodec)
.typecase(37002, spliceAckCodec) .typecase(37002, spliceAckCodec)
.typecase(37004, spliceLockedCodec) .typecase(37004, spliceLockedCodec)

View File

@ -254,6 +254,7 @@ case class OpenDualFundedChannel(chainHash: BlockHash,
val channelType_opt: Option[ChannelType] = tlvStream.get[ChannelTlv.ChannelTypeTlv].map(_.channelType) val channelType_opt: Option[ChannelType] = tlvStream.get[ChannelTlv.ChannelTypeTlv].map(_.channelType)
val requireConfirmedInputs: Boolean = tlvStream.get[ChannelTlv.RequireConfirmedInputsTlv].nonEmpty val requireConfirmedInputs: Boolean = tlvStream.get[ChannelTlv.RequireConfirmedInputsTlv].nonEmpty
val requestFunding_opt: Option[LiquidityAds.RequestFunding] = tlvStream.get[ChannelTlv.RequestFundingTlv].map(_.request) 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) 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 { tlvStream: TlvStream[SpliceInitTlv] = TlvStream.empty) extends ChannelMessage with HasChannelId {
val requireConfirmedInputs: Boolean = tlvStream.get[ChannelTlv.RequireConfirmedInputsTlv].nonEmpty val requireConfirmedInputs: Boolean = tlvStream.get[ChannelTlv.RequireConfirmedInputsTlv].nonEmpty
val requestFunding_opt: Option[LiquidityAds.RequestFunding] = tlvStream.get[ChannelTlv.RequestFundingTlv].map(_.request) 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) val pushAmount: MilliSatoshi = tlvStream.get[ChannelTlv.PushAmountTlv].map(_.amount).getOrElse(0 msat)
} }
@ -331,11 +333,12 @@ case class SpliceAck(channelId: ByteVector32,
} }
object SpliceAck { 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( val tlvs: Set[SpliceAckTlv] = Set(
if (pushAmount > 0.msat) Some(ChannelTlv.PushAmountTlv(pushAmount)) else None, if (pushAmount > 0.msat) Some(ChannelTlv.PushAmountTlv(pushAmount)) else None,
if (requireConfirmedInputs) Some(ChannelTlv.RequireConfirmedInputsTlv()) 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 ).flatten
SpliceAck(channelId, fundingContribution, fundingPubKey, TlvStream(tlvs)) 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))) 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 case class UnknownMessage(tag: Int, data: ByteVector) extends LightningMessage

View File

@ -23,7 +23,7 @@ import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel._
import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.transactions.Transactions
import fr.acinq.eclair.wire.protocol.CommonCodecs._ 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 fr.acinq.eclair.{MilliSatoshi, ToMilliSatoshiConversion, UInt64}
import scodec.Codec import scodec.Codec
import scodec.bits.{BitVector, ByteVector} import scodec.bits.{BitVector, ByteVector}
@ -124,7 +124,7 @@ object LiquidityAds {
/** Sellers offer various rates and payment options. */ /** Sellers offer various rates and payment options. */
case class WillFundRates(fundingRates: List[FundingRate], paymentTypes: Set[PaymentType]) { 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)) { if (!paymentTypes.contains(request.paymentDetails.paymentType)) {
Left(InvalidLiquidityAdsPaymentType(channelId, request.paymentDetails.paymentType, paymentTypes)) Left(InvalidLiquidityAdsPaymentType(channelId, request.paymentDetails.paymentType, paymentTypes))
} else if (!fundingRates.contains(request.fundingRate)) { } else if (!fundingRates.contains(request.fundingRate)) {
@ -133,7 +133,11 @@ object LiquidityAds {
Left(InvalidLiquidityAdsRate(channelId)) Left(InvalidLiquidityAdsRate(channelId))
} else { } else {
val sig = Crypto.sign(request.fundingRate.signedData(fundingScript), nodeKey) 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)) 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 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 { (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) case _ => Right(None)
} }
} }
@ -225,7 +229,11 @@ object LiquidityAds {
} }
object Purchase { object Purchase {
// @formatter:off
case class Standard(amount: Satoshi, fees: Fees, paymentDetails: PaymentDetails) extends Purchase() 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) case class WillFundPurchase(willFund: WillFund, purchase: Purchase)

View File

@ -246,6 +246,7 @@ object TestConstants {
None, None,
None, None,
isChannelOpener = true, isChannelOpener = true,
paysCommitTxFees = true,
dualFunded = false, dualFunded = false,
fundingSatoshis, fundingSatoshis,
unlimitedMaxHtlcValueInFlight = false, unlimitedMaxHtlcValueInFlight = false,
@ -419,6 +420,7 @@ object TestConstants {
None, None,
None, None,
isChannelOpener = false, isChannelOpener = false,
paysCommitTxFees = false,
dualFunded = false, dualFunded = false,
fundingSatoshis, fundingSatoshis,
unlimitedMaxHtlcValueInFlight = false, unlimitedMaxHtlcValueInFlight = false,

View File

@ -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 = { 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 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 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 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, dualFunded = true, fundingAmountB, unlimitedMaxHtlcValueInFlight = false).copy(paysCommitTxFees = nonInitiatorPaysCommitTxFees) 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 { val Seq(remoteParamsA, remoteParamsB) = Seq((nodeParamsA, localParamsA), (nodeParamsB, localParamsB)).map {
case (nodeParams, localParams) => 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") { test("initiator and non-initiator splice-in") {
val targetFeerate = FeeratePerKw(1000 sat) val targetFeerate = FeeratePerKw(1000 sat)
// We chose those amounts to ensure that Bob always signs first: // 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)) val bobSplice = params.spawnTxBuilderSpliceBob(spliceParams, previousCommitment, wallet, Some(purchase))
bobSplice ! Start(probe.ref) bobSplice ! Start(probe.ref)
assert(probe.expectMsgType[LocalFailure].cause == InvalidFundingBalances(params.channelId, 620_000 sat, 625_000_000 msat, -5_000_000 msat)) 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. // 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)))) val bobFutureHtlc = params.spawnTxBuilderBob(wallet, params.fundingParamsB, Some(purchase.copy(paymentDetails = LiquidityAds.PaymentDetails.FromFutureHtlc(Nil))))
bobFutureHtlc ! Start(probe.ref) bobFutureHtlc ! Start(probe.ref)

View File

@ -108,6 +108,19 @@ class WaitForOpenDualFundedChannelStateSpec extends TestKitBaseClass with Fixtur
assert(accept.willFund_opt.nonEmpty) 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 => test("recv OpenDualFundedChannel (with push amount)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f =>
import f._ import f._

View File

@ -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)
}
}
} }

View File

@ -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.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 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.funsuite.FixtureAnyFunSuiteLike
import org.scalatest.{Outcome, Tag}
import java.util.UUID import java.util.UUID
import scala.concurrent.duration.DurationInt import scala.concurrent.duration.DurationInt
@ -42,6 +42,8 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
import OnTheFlyFundingSpec._ import OnTheFlyFundingSpec._
val withFeeCredit = "with_fee_credit"
val remoteFeatures = Features( val remoteFeatures = Features(
Features.StaticRemoteKey -> FeatureSupport.Optional, Features.StaticRemoteKey -> FeatureSupport.Optional,
Features.AnchorOutputsZeroFeeHtlcTx -> FeatureSupport.Optional, Features.AnchorOutputsZeroFeeHtlcTx -> FeatureSupport.Optional,
@ -50,6 +52,13 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
Features.OnTheFlyFunding -> FeatureSupport.Optional, 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) { 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 = { 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()) val localInit = protocol.Init(nodeParams.features.initFeatures())
@ -110,13 +119,40 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
channelId: ByteVector32 = randomBytes32(), channelId: ByteVector32 = randomBytes32(),
fees: LiquidityAds.Fees = LiquidityAds.Fees(0 sat, 0 sat), fees: LiquidityAds.Fees = LiquidityAds.Fees(0 sat, 0 sat),
fundingTxIndex: Long = 0, fundingTxIndex: Long = 0,
htlcMinimum: MilliSatoshi = 1 msat): LiquidityPurchaseSigned = { htlcMinimum: MilliSatoshi = 1 msat,
val purchase = LiquidityAds.Purchase.Standard(amount, fees, paymentDetails) 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) val event = LiquidityPurchaseSigned(channelId, TxId(randomBytes32()), fundingTxIndex, htlcMinimum, purchase)
peer ! event peer ! event
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 = { 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) val commitments = CommitmentsSpec.makeCommitments(500_000_000 msat, 500_000_000 msat, nodeParams.nodeId, remoteNodeId, announceChannel = false)
.modify(_.params.remoteParams.htlcMinimum).setTo(htlcMinimum) .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.DualFunding -> FeatureSupport.Optional))
.modify(_.features.activated).using(_ + (Features.SplicePrototype -> FeatureSupport.Optional)) .modify(_.features.activated).using(_ + (Features.SplicePrototype -> FeatureSupport.Optional))
.modify(_.features.activated).using(_ + (Features.OnTheFlyFunding -> 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 remoteNodeId = randomKey().publicKey
val register = TestProbe() val register = TestProbe()
val channel = 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 => test("proposed on-the-fly funding timeout") { f =>
import f._ import f._
@ -285,6 +341,22 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
peerConnection.expectNoMessage(100 millis) 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 => test("proposed on-the-fly funding HTLC timeout") { f =>
import f._ import f._
@ -336,6 +408,22 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
awaitCond(nodeParams.db.liquidity.listPendingOnTheFlyFunding(remoteNodeId).isEmpty, interval = 100 millis) 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 => test("signed on-the-fly funding HTLC timeout after disconnection") { f =>
import f._ import f._
@ -379,6 +467,74 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
probe.expectTerminated(peerAfterRestart.ref) 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 => test("receive open_channel2") { f =>
import 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))) assert(init.fundingContribution_opt.contains(LiquidityAds.AddFunding(requestFunding.requestedAmount, nodeParams.willFundRates_opt)))
// The preimage was provided, so we fulfill upstream HTLCs. // The preimage was provided, so we fulfill upstream HTLCs.
val fwd = register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]] verifyFulfilledUpstream(upstream, preimage)
assert(fwd.channelId == upstream.add.channelId) }
assert(fwd.message.id == upstream.add.id)
assert(fwd.message.r == 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 => test("receive splice_init") { f =>
@ -427,10 +636,41 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
channel.expectNoMessage(100 millis) channel.expectNoMessage(100 millis)
// The preimage was provided, so we fulfill upstream HTLCs. // The preimage was provided, so we fulfill upstream HTLCs.
val fwd = register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]] verifyFulfilledUpstream(upstream, preimage)
assert(fwd.channelId == upstream.add.channelId) }
assert(fwd.message.id == upstream.add.id)
assert(fwd.message.r == 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 => 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 (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)) 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))) 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]] verifyFulfilledUpstream(upstream1, preimage1)
assert(fwd1.channelId == upstream1.add.channelId)
assert(fwd1.message.id == upstream1.add.id)
assert(fwd1.message.r == preimage1)
add2.replyTo ! RES_ADD_SETTLED(add2.origin, outgoing.last, HtlcResult.OnChainFulfill(preimage2)) add2.replyTo ! RES_ADD_SETTLED(add2.origin, outgoing.last, HtlcResult.OnChainFulfill(preimage2))
val fwd2 = register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]] verifyFulfilledUpstream(upstream2, preimage2)
assert(fwd2.channelId == upstream2.add.channelId)
assert(fwd2.message.id == upstream2.add.id)
assert(fwd2.message.r == preimage2)
awaitCond(nodeParams.db.liquidity.listPendingOnTheFlyFunding(remoteNodeId).isEmpty, interval = 100 millis) awaitCond(nodeParams.db.liquidity.listPendingOnTheFlyFunding(remoteNodeId).isEmpty, interval = 100 millis)
register.expectNoMessage(100 millis) register.expectNoMessage(100 millis)
} }
@ -732,12 +966,94 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
// The payment is fulfilled by our peer. // The payment is fulfilled by our peer.
cmd2.replyTo ! RES_ADD_SETTLED(cmd2.origin, htlc, HtlcResult.OnChainFulfill(preimage)) 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) nodeParams.db.liquidity.addOnTheFlyFundingPreimage(preimage)
register.expectNoMessage(100 millis) register.expectNoMessage(100 millis)
awaitCond(nodeParams.db.liquidity.listPendingOnTheFlyFunding(remoteNodeId).isEmpty, interval = 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)
cmd.replyTo ! RES_SUCCESS(cmd, purchase.channelId)
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 => test("don't relay payments too close to expiry") { f =>
import f._ import f._
@ -773,10 +1089,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.expectMsgType[CMD_GET_CHANNEL_INFO].replyTo ! RES_GET_CHANNEL_INFO(remoteNodeId, purchase.channelId, channel.ref, NORMAL, makeChannelData())
channel.expectNoMessage(100 millis) channel.expectNoMessage(100 millis)
val fwd = register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]] verifyFulfilledUpstream(upstream, preimage)
assert(fwd.channelId == upstream.add.channelId)
assert(fwd.message.id == upstream.add.id)
assert(fwd.message.r == preimage)
register.expectNoMessage(100 millis) register.expectNoMessage(100 millis)
} }

View File

@ -28,7 +28,7 @@ import fr.acinq.eclair.channel.{ChannelFlags, ChannelTypes}
import fr.acinq.eclair.json.JsonSerializers import fr.acinq.eclair.json.JsonSerializers
import fr.acinq.eclair.router.Announcements import fr.acinq.eclair.router.Announcements
import fr.acinq.eclair.transactions.Scripts 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.LightningMessageCodecs._
import fr.acinq.eclair.wire.protocol.ReplyChannelRangeTlv._ import fr.acinq.eclair.wire.protocol.ReplyChannelRangeTlv._
import org.json4s.jackson.Serialization import org.json4s.jackson.Serialization
@ -372,7 +372,9 @@ class LightningMessageCodecsSpec extends AnyFunSuite {
defaultAccept -> defaultEncoded, defaultAccept -> defaultEncoded,
defaultAccept.copy(tlvStream = TlvStream(ChannelTypeTlv(ChannelTypes.StaticRemoteKey()))) -> (defaultEncoded ++ hex"01021000"), 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.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) => testCases.foreach { case (accept, bin) =>
val decoded = lightningMessageCodec.decode(bin.bits).require.value 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, (-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", 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, 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, 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) -> 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", SpliceLocked(channelId, fundingTxId) -> hex"908c aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 24e1b2c94c4e734dd5b9c5f3c910fbb6b3b436ced6382c7186056a5a23f14566",
// @formatter:on // @formatter:on
) )
@ -464,7 +468,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite {
val open = defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.RequestFundingTlv(request))) val open = defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.RequestFundingTlv(request)))
val openBin = hex"fd053b 1e 00000000000b71b0 0007a120004c4b40044c004b00000000000005dc 0000" val openBin = hex"fd053b 1e 00000000000b71b0 0007a120004c4b40044c004b00000000000005dc 0000"
assert(lightningMessageCodec.encode(open).require.bytes == defaultOpenBin ++ openBin) 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 accept = defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund)))
val acceptBin = hex"fd053b 78 0007a120004c4b40044c004b00000000000005dc 002200202ec38203f4cf37a3b377d9a55c7ae0153c643046dbdbe2ffccfb11b74420103c c57cf393f6bd534472ec08cbfbbc7268501b32f563a21cdf02a99127c4f25168249acd6509f96b2e93843c3b838ee4808c75d0a15ff71ba886fda980b8ca954f" val acceptBin = hex"fd053b 78 0007a120004c4b40044c004b00000000000005dc 002200202ec38203f4cf37a3b377d9a55c7ae0153c643046dbdbe2ffccfb11b74420103c c57cf393f6bd534472ec08cbfbbc7268501b32f563a21cdf02a99127c4f25168249acd6509f96b2e93843c3b838ee4808c75d0a15ff71ba886fda980b8ca954f"
assert(lightningMessageCodec.encode(accept).require.bytes == defaultAcceptBin ++ acceptBin) 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 open = defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.RequestFundingTlv(request)))
val openBin = hex"fd053b 5e 000000000007a120 000186a00007a1200226006400001388000003e8 804080417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734d662b36d54c6d1c2a0227cdc114d12c578c25ab6ec664eebaa440d7e493eba47" val openBin = hex"fd053b 5e 000000000007a120 000186a00007a1200226006400001388000003e8 804080417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734d662b36d54c6d1c2a0227cdc114d12c578c25ab6ec664eebaa440d7e493eba47"
assert(lightningMessageCodec.encode(open).require.bytes == defaultOpenBin ++ openBin) 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 accept = defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund)))
val acceptBin = hex"fd053b 78 000186a00007a1200226006400001388000003e8 002200202ec38203f4cf37a3b377d9a55c7ae0153c643046dbdbe2ffccfb11b74420103c 035875ad2279190f6bfcc75a8bdccafeddfc2700a03587e3621114bf43b60d2c0de977ba0337b163d320471720a683ae211bea07742a2c4204dd5eb0bda75135" val acceptBin = hex"fd053b 78 000186a00007a1200226006400001388000003e8 002200202ec38203f4cf37a3b377d9a55c7ae0153c643046dbdbe2ffccfb11b74420103c 035875ad2279190f6bfcc75a8bdccafeddfc2700a03587e3621114bf43b60d2c0de977ba0337b163d320471720a683ae211bea07742a2c4204dd5eb0bda75135"
assert(lightningMessageCodec.encode(accept).require.bytes == defaultAcceptBin ++ acceptBin) 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 open = defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.RequestFundingTlv(request)))
val openBin = hex"fd053b 5e 000000000007a120 000186a00007a1200226006400001388000003e8 824080417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734d662b36d54c6d1c2a0227cdc114d12c578c25ab6ec664eebaa440d7e493eba47" val openBin = hex"fd053b 5e 000000000007a120 000186a00007a1200226006400001388000003e8 824080417c0c91deb72606958425ea1552a045a55a250e91870231b486dcb2106734d662b36d54c6d1c2a0227cdc114d12c578c25ab6ec664eebaa440d7e493eba47"
assert(lightningMessageCodec.encode(open).require.bytes == defaultOpenBin ++ openBin) 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 accept = defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.ProvideFundingTlv(willFund)))
val acceptBin = hex"fd053b 78 000186a00007a1200226006400001388000003e8 002200202ec38203f4cf37a3b377d9a55c7ae0153c643046dbdbe2ffccfb11b74420103c 035875ad2279190f6bfcc75a8bdccafeddfc2700a03587e3621114bf43b60d2c0de977ba0337b163d320471720a683ae211bea07742a2c4204dd5eb0bda75135" val acceptBin = hex"fd053b 78 000186a00007a1200226006400001388000003e8 002200202ec38203f4cf37a3b377d9a55c7ae0153c643046dbdbe2ffccfb11b74420103c 035875ad2279190f6bfcc75a8bdccafeddfc2700a03587e3621114bf43b60d2c0de977ba0337b163d320471720a683ae211bea07742a2c4204dd5eb0bda75135"
assert(lightningMessageCodec.encode(accept).require.bytes == defaultAcceptBin ++ acceptBin) 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") { test("unknown messages") {
// Non-standard tag number so this message can only be handled by a codec with a fallback // 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) val unknown = UnknownMessage(tag = 47282, data = ByteVector32.Zeroes.bytes)

View File

@ -40,7 +40,7 @@ class LiquidityAdsSpec extends AnyFunSuite {
val fundingRates = LiquidityAds.WillFundRates(fundingRate :: Nil, Set(LiquidityAds.PaymentType.FromChannelBalance)) val fundingRates = LiquidityAds.WillFundRates(fundingRate :: Nil, Set(LiquidityAds.PaymentType.FromChannelBalance))
val Some(request) = LiquidityAds.requestFunding(500_000 sat, LiquidityAds.PaymentDetails.FromChannelBalance, fundingRates) val Some(request) = LiquidityAds.requestFunding(500_000 sat, LiquidityAds.PaymentDetails.FromChannelBalance, fundingRates)
val fundingScript = hex"00202395c9c52c02ca069f1d56a3c6124bf8b152a617328c76e6b31f83ace370c2ff" 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.fundingRate == fundingRate)
assert(willFund.fundingScript == fundingScript) assert(willFund.fundingScript == fundingScript)
assert(willFund.signature == ByteVector64.fromValidHex("a53106bd20027b0215480ff0b06b2bf9324bb257c2a0e74c2604ec347493f90d3a975d56a68b21a6cc48d6763d96f70e1d630dd1720cf6b7314d4304050fe265")) assert(willFund.signature == ByteVector64.fromValidHex("a53106bd20027b0215480ff0b06b2bf9324bb257c2a0e74c2604ec347493f90d3a975d56a68b21a6cc48d6763d96f70e1d630dd1720cf6b7314d4304050fe265"))